diff --git a/.env b/.env index 7eeb4b4..9045cea 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ PROXY_CACHE_LOG_LEVEL=debug REDIS_HOST=localhost -REDIS_PORT=6379 +REDIS_STANDALONE_PORT=6379 +REDIS_CLUSTER_PORT=16379 diff --git a/docker-compose.yml b/docker-compose.yml index 4f912bf..a5c64cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,19 +22,26 @@ services: - redis-node-4 - redis-node-5 ports: - - "6379:6379" + - "${REDIS_CLUSTER_PORT}:6379" redis-node-1: <<: *REDIS_NODE - redis-node-2: <<: *REDIS_NODE - redis-node-3: <<: *REDIS_NODE - redis-node-4: <<: *REDIS_NODE - redis-node-5: <<: *REDIS_NODE + + + redis: + image: redis:6.2.4-alpine + restart: "unless-stopped" + environment: + - ALLOW_EMPTY_PASSWORD=yes + - REDIS_PORT=6379 + - REDIS_REPLICATION_MODE=master + ports: + - "${REDIS_STANDALONE_PORT}:6379" diff --git a/package-lock.json b/package-lock.json index ee4d8e3..c2d3dac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@mojaloop/inter-scheme-proxy-cache-lib", - "version": "2.0.0-snapshot.1", + "version": "2.2.0-snapshot.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mojaloop/inter-scheme-proxy-cache-lib", - "version": "2.0.0-snapshot.1", + "version": "2.2.0-snapshot.0", "license": "Apache-2.0", "dependencies": { - "@mojaloop/central-services-logger": "11.4.5", + "@mojaloop/central-services-logger": "11.5.0", "ajv": "^8.17.1", "convict": "^6.2.4", "fast-safe-stringify": "^2.1.1", @@ -27,7 +27,7 @@ "eslint": "8.56.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-import": "2.29.1", - "husky": "^9.1.1", + "husky": "^9.1.2", "ioredis-mock": "^8.9.0", "jest": "^29.7.0", "jest-junit": "^16.0.0", @@ -39,7 +39,7 @@ "standard-version": "^9.5.0", "ts-jest": "29.2.3", "ts-node": "10.9.2", - "tsup": "^8.2.2", + "tsup": "^8.2.3", "typedoc": "^0.26.5", "typedoc-theme-hierarchy": "^5.0.3", "typescript": "5.5.4" @@ -2268,9 +2268,9 @@ "optional": true }, "node_modules/@mojaloop/central-services-logger": { - "version": "11.4.5", - "resolved": "https://registry.npmjs.org/@mojaloop/central-services-logger/-/central-services-logger-11.4.5.tgz", - "integrity": "sha512-nCKEIinB/Zx3routZhcGd+//IKd9oThpGggTde4rNLJ6O4nVJgHSW6pZIzd1T+Mj34yBhOPidhBa0piLBKcZtQ==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/@mojaloop/central-services-logger/-/central-services-logger-11.5.0.tgz", + "integrity": "sha512-pH73RiJ5fKTBTSdLocp1vPBad1D+Kh0HufdcfjLaBQj3dIBq72si0k+Z3L1MeOmMqMzpj+8M/he/izlgqJjVJA==", "dependencies": { "parse-strings-in-object": "2.0.0", "rc": "1.2.8", @@ -8750,9 +8750,9 @@ } }, "node_modules/husky": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.1.tgz", - "integrity": "sha512-fCqlqLXcBnXa/TJXmT93/A36tJsjdJkibQ1MuIiFyCCYUlpYpIaj2mv1w+3KR6Rzu1IC3slFTje5f6DUp2A2rg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.2.tgz", + "integrity": "sha512-1/aDMXZdhr1VdJJTLt6e7BipM0Jd9qkpubPiIplon1WmCeOy3nnzsCMeBqS9AsL5ioonl8F8y/F2CLOmk19/Pw==", "dev": true, "bin": { "husky": "bin.js" @@ -15957,9 +15957,9 @@ } }, "node_modules/tsup": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.2.2.tgz", - "integrity": "sha512-MufIuzdSt6HYPOeOtjUXLR4rqRJySi6XsRNZdwvjC2XR+xghsu2L3vSmYmX+k4S1mO6j0OlUEyVQ3Fc0H66XcA==", + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.2.3.tgz", + "integrity": "sha512-6YNT44oUfXRbZuSMNmN36GzwPPIlD2wBccY7looM2fkTcxkf2NEmwr3OZuDZoySklnrIG4hoEtzy8yUXYOqNcg==", "dev": true, "dependencies": { "bundle-require": "^5.0.0", diff --git a/package.json b/package.json index 325c741..7ed7314 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojaloop/inter-scheme-proxy-cache-lib", - "version": "2.0.0-snapshot.1", + "version": "2.2.0-snapshot.0", "description": "Common component, that provides scheme proxy caching mapping (ISPC)", "author": "Eugen Klymniuk (geka-evk)", "contributors": [ @@ -35,7 +35,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@mojaloop/central-services-logger": "11.4.5", + "@mojaloop/central-services-logger": "11.5.0", "ajv": "^8.17.1", "convict": "^6.2.4", "fast-safe-stringify": "^2.1.1", @@ -53,7 +53,7 @@ "eslint": "8.56.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-import": "2.29.1", - "husky": "^9.1.1", + "husky": "^9.1.2", "ioredis-mock": "^8.9.0", "jest": "^29.7.0", "jest-junit": "^16.0.0", @@ -65,7 +65,7 @@ "standard-version": "^9.5.0", "ts-jest": "29.2.3", "ts-node": "10.9.2", - "tsup": "^8.2.2", + "tsup": "^8.2.3", "typedoc": "^0.26.5", "typedoc-theme-hierarchy": "^5.0.3", "typescript": "5.5.4" diff --git a/src/constants.ts b/src/constants.ts index fe42432..0d5db71 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,6 @@ export const STORAGE_TYPES = { redis: 'redis', + redisCluster: 'redis-cluster', mysql: 'mysql', } as const; diff --git a/src/lib/createProxyCache.ts b/src/lib/createProxyCache.ts index f27dd15..1aecd6c 100644 --- a/src/lib/createProxyCache.ts +++ b/src/lib/createProxyCache.ts @@ -24,7 +24,7 @@ **********/ import { ProxyCacheFactory, StorageType, ProxyCacheConfig } from '../types'; -import { validateRedisProxyCacheConfig } from '../validation'; +import { validateRedisClusterProxyCacheConfig, validateRedisProxyCacheConfig } from '../validation'; import { logger } from '../utils'; import { STORAGE_TYPES } from '../constants'; import { ProxyCacheError } from './errors'; @@ -35,6 +35,9 @@ export const createProxyCache: ProxyCacheFactory = (type: StorageType, proxyConf case STORAGE_TYPES.redis: { return new storages.RedisProxyCache(validateRedisProxyCacheConfig(proxyConfig)); } + case STORAGE_TYPES.redisCluster: { + return new storages.RedisProxyCache(validateRedisClusterProxyCacheConfig(proxyConfig)); + } case STORAGE_TYPES.mysql: throw new Error('Mysql storage is not implemented yet'); default: { diff --git a/src/lib/storages/RedisProxyCache.ts b/src/lib/storages/RedisProxyCache.ts index d51d820..6bba548 100644 --- a/src/lib/storages/RedisProxyCache.ts +++ b/src/lib/storages/RedisProxyCache.ts @@ -1,17 +1,29 @@ -import { Cluster } from 'ioredis'; +import Redis, { Cluster } from 'ioredis'; import * as validation from '../../validation'; import config from '../../config'; import { createLogger } from '../../utils'; -import { IProxyCache, RedisProxyCacheConfig, IsLastFailure, AlsRequestDetails, ILogger } from '../../types'; +import { + IProxyCache, + RedisProxyCacheConfig, + RedisClusterProxyCacheConfig, + IsLastFailure, + AlsRequestDetails, + ILogger, +} from '../../types'; import { REDIS_KEYS_PREFIXES, REDIS_SUCCESS, REDIS_IS_CONNECTED_STATUSES } from './constants'; +type RedisClient = Redis | Cluster; +type RedisConfig = RedisProxyCacheConfig | RedisClusterProxyCacheConfig; + +const isClusterConfig = (config: RedisConfig): config is RedisClusterProxyCacheConfig => 'cluster' in config; + export class RedisProxyCache implements IProxyCache { - private readonly redisClient: Cluster; + private readonly redisClient: RedisClient; private readonly log: ILogger; private readonly defaultTtlSec = config.get('defaultTtlSec'); - constructor(private readonly proxyConfig: RedisProxyCacheConfig) { + constructor(private readonly proxyConfig: RedisConfig) { this.log = createLogger(this.constructor.name); this.redisClient = this.createRedisClient(); } @@ -118,11 +130,18 @@ export class RedisProxyCache implements IProxyCache { } private createRedisClient() { - const { log } = this; - const { cluster, ...redisOptions } = this.proxyConfig; - const { lazyConnect = true } = redisOptions; + this.proxyConfig.lazyConnect ??= true; + // prettier-ignore + const redisClient = isClusterConfig(this.proxyConfig) + ? new Cluster(this.proxyConfig.cluster, this.proxyConfig) + : new Redis(this.proxyConfig); + + this.addEventListeners(redisClient); + return redisClient; + } - const redisClient = new Cluster(cluster, { ...redisOptions, lazyConnect }); + private addEventListeners(redisClient: RedisClient) { + const { log } = this; // prettier-ignore redisClient .on('error', (err) => { log.error('redis connection error', err); }) @@ -131,8 +150,6 @@ export class RedisProxyCache implements IProxyCache { .on('reconnecting', (ms: number) => { log.info('redis connection reconnecting', { ms }); }) .on('connect', () => { log.verbose('redis connection is established'); }) .on('ready', () => { log.verbose('redis connection is ready'); }); - - return redisClient; } private async executePipeline(commands: [string, ...any[]][]): Promise { diff --git a/src/types/lib.ts b/src/types/lib.ts index 7920ad1..f14ac46 100644 --- a/src/types/lib.ts +++ b/src/types/lib.ts @@ -39,13 +39,14 @@ export type IsLastFailure = boolean; export type ProxyCacheFactory = (type: StorageType, proxyConfig: ProxyCacheConfig) => IProxyCache; // todo: think about making proxyConfig optional, and assemble it using env vars if it wasn't passed -export type ProxyCacheConfig = RedisProxyCacheConfig | MySqlProxyCacheConfig; +export type ProxyCacheConfig = RedisProxyCacheConfig | RedisClusterProxyCacheConfig | MySqlProxyCacheConfig; -export type RedisProxyCacheConfig = Prettify; +export type RedisProxyCacheConfig = Prettify; -export type RedisConnectionConfig = { +export type RedisClusterProxyCacheConfig = Prettify; + +export type RedisClusterConnectionConfig = { cluster: BasicConnectionConfig[]; - // todo: think, if it's better to add also { host, port } options for standalone redis }; export type RedisOptions = { @@ -57,6 +58,8 @@ export type RedisOptions = { // define all needed options here }; +export type RedisClusterOptions = RedisOptions; + /** **(!)** _MySqlProxyCacheConfig_ is not supported yet */ // prettier-ignore export type MySqlProxyCacheConfig = Prettify = { + type: 'object', + properties: { + host: { type: 'string', minLength: 1 }, + port: { type: 'integer' }, + // todo: think, how to avoid duplication of the same fields for both redis schemas + username: { type: 'string', nullable: true }, + password: { type: 'string', nullable: true }, + lazyConnect: { type: 'boolean', nullable: true }, + db: { type: 'number', nullable: true }, + // find a better way to define optional params (without nullable: true) + }, + required: ['host', 'port'], + additionalProperties: true, +}; +const redisProxyCacheConfigValidatingFn = ajv.compile(RedisProxyCacheConfigSchema); + +export const validateRedisProxyCacheConfig = (cacheConfig: unknown): RedisProxyCacheConfig => { + const isValid = redisProxyCacheConfigValidatingFn(cacheConfig); + if (!isValid) { + const errDetails = `redisProxyCacheConfig error: ${redisProxyCacheConfigValidatingFn.errors![0]!.message}`; + throw ValidationError.invalidFormat(errDetails); + } + return cacheConfig; +}; + const ClusterSchema: JSONSchemaType = { type: 'object', properties: { - host: { type: 'string' }, + host: { type: 'string', minLength: 1 }, port: { type: 'integer' }, }, required: ['host', 'port'], additionalProperties: false, }; -const RedisProxyCacheConfigSchema: JSONSchemaType = { +const RedisClusterProxyCacheConfigSchema: JSONSchemaType = { type: 'object', properties: { cluster: { type: 'array', items: ClusterSchema, minItems: 1 }, @@ -55,12 +81,15 @@ const RedisProxyCacheConfigSchema: JSONSchemaType = { required: ['cluster'], additionalProperties: true, }; -const redisProxyCacheConfigValidatingFn = ajv.compile(RedisProxyCacheConfigSchema); -export const validateRedisProxyCacheConfig = (cacheConfig: unknown): RedisProxyCacheConfig => { - const isValid = redisProxyCacheConfigValidatingFn(cacheConfig); +const redisClusterProxyCacheConfigValidatingFn = ajv.compile( + RedisClusterProxyCacheConfigSchema, +); + +export const validateRedisClusterProxyCacheConfig = (cacheConfig: unknown): RedisClusterProxyCacheConfig => { + const isValid = redisClusterProxyCacheConfigValidatingFn(cacheConfig); if (!isValid) { - const errDetails = `redisProxyCacheConfig: ${redisProxyCacheConfigValidatingFn.errors![0]!.message}`; + const errDetails = `redisClusterProxyCacheConfig error: ${redisClusterProxyCacheConfigValidatingFn.errors![0]!.message}`; throw ValidationError.invalidFormat(errDetails); } return cacheConfig; diff --git a/test/fixtures.ts b/test/fixtures.ts index f2e90e1..d6ca0d3 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1,17 +1,28 @@ -import { RedisProxyCacheConfig, AlsRequestDetails } from '#src/types'; +import { randomUUID } from 'node:crypto'; +import { RedisProxyCacheConfig, RedisClusterProxyCacheConfig, AlsRequestDetails } from '#src/types'; export const redisProxyConfigDto = ({ - cluster = [{ host: '127.0.0.1', port: 16379 }], + host = '127.0.0.1', + port = 26379, lazyConnect = true, } = {}): RedisProxyCacheConfig => ({ + host, + port, + lazyConnect, +}); + +export const redisClusterProxyConfigDto = ({ + cluster = [{ host: '127.0.0.1', port: 56379 }], + lazyConnect = true, +} = {}): RedisClusterProxyCacheConfig => ({ cluster, lazyConnect, }); export const alsRequestDetailsDto = ({ - sourceId = 'test-source', + sourceId = `test-source-${Date.now()}`, type = 'MSISDN', // todo: use enum for type - partyId = `${Date.now()}`, + partyId = `party-${randomUUID()}`, } = {}): AlsRequestDetails => ({ sourceId, type, diff --git a/test/integration/lib/storages/RedisProxyCache.int.test.ts b/test/integration/lib/storages/RedisProxyCache.int.test.ts index a7d2a87..b92c673 100644 --- a/test/integration/lib/storages/RedisProxyCache.int.test.ts +++ b/test/integration/lib/storages/RedisProxyCache.int.test.ts @@ -30,25 +30,26 @@ import { logger } from '#src/utils'; import * as useCases from '#test/useCases'; import * as fixtures from '#test/fixtures'; -const port = parseInt(env.REDIS_PORT || ''); +const port = parseInt(env.REDIS_STANDALONE_PORT || ''); +const portCluster = parseInt(env.REDIS_CLUSTER_PORT || ''); // todo: use convict -const cluster = [{ host: 'localhost', port }]; -const redisProxyConfig = fixtures.redisProxyConfigDto({ cluster }); -logger.info('redisProxyConfig', redisProxyConfig); -describe('RedisProxyCache Integration Tests -->', () => { - let proxyCache: IProxyCache; +const redisProxyConfig = fixtures.redisProxyConfigDto({ port }); +const redisClusterProxyConfig = fixtures.redisClusterProxyConfigDto({ + cluster: [{ host: 'localhost', port: portCluster }], +}); +logger.info('redis proxyConfigs', { redisClusterProxyConfig, redisProxyConfig }); - beforeAll(async () => { - proxyCache = createProxyCache(STORAGE_TYPES.redis, redisProxyConfig); - await proxyCache.connect(); - }); +describe('RedisProxyCache Integration Tests -->', () => { + const runUseCases = (proxyCache: IProxyCache, anotherProxyCache: IProxyCache) => { + beforeAll(async () => { + await Promise.all([proxyCache.connect(), anotherProxyCache.connect()]); + }); - afterAll(async () => { - await proxyCache.disconnect(); - }); + afterAll(async () => { + await Promise.all([proxyCache.disconnect(), anotherProxyCache.disconnect()]); + }); - describe('Use Cases Tests -->', () => { test('should perform proxyMapping use case', async () => { await useCases.proxyMappingUseCase(proxyCache); }); @@ -57,35 +58,29 @@ describe('RedisProxyCache Integration Tests -->', () => { await useCases.detectFinalErrorCallbackUseCase(proxyCache); }); + test('should save only the first alsRequest', async () => { + await useCases.setSendToProxiesListOnceUseCase(proxyCache); + }); + test('should have shared db info for all connected instances', async () => { - const anotherProxyCache = createProxyCache(STORAGE_TYPES.redis, redisProxyConfig); - await anotherProxyCache.connect(); - const alsReq = fixtures.alsRequestDetailsDto(); - const proxyId = 'proxyAB'; + await useCases.shareDbInfoForAllConnectedInstances(proxyCache, anotherProxyCache); + }); - const isOk = await proxyCache.setSendToProxiesList(alsReq, [proxyId], 1); + test('should have healthCheck method', async () => { + const isOk = await proxyCache.healthCheck(); expect(isOk).toBe(true); - - const isLast = await anotherProxyCache.receivedErrorResponse(alsReq, proxyId); - expect(isLast).toBe(true); - - await anotherProxyCache.disconnect(); }); - }); - - test('should save only the first alsRequest', async () => { - const alsReq = fixtures.alsRequestDetailsDto(); - const proxyIds = ['proxy1', 'proxy2', 'proxy3']; + }; - const [isOk1, isOk2] = await Promise.all([ - proxyCache.setSendToProxiesList(alsReq, proxyIds, 1), - proxyCache.setSendToProxiesList(alsReq, proxyIds, 1), - ]); - expect(isOk1).not.toBe(isOk2); + describe('Use Cases Tests with redis cluster -->', () => { + const proxyCache = createProxyCache(STORAGE_TYPES.redisCluster, redisClusterProxyConfig); + const anotherProxyCache = createProxyCache(STORAGE_TYPES.redisCluster, redisClusterProxyConfig); + runUseCases(proxyCache, anotherProxyCache); }); - test('should have healthCheck method', async () => { - const isOk = await proxyCache.healthCheck(); - expect(isOk).toBe(true); + describe('Use Cases Tests with standalone redis -->', () => { + const proxyCache = createProxyCache(STORAGE_TYPES.redis, redisProxyConfig); + const anotherProxyCache = createProxyCache(STORAGE_TYPES.redis, redisProxyConfig); + runUseCases(proxyCache, anotherProxyCache); }); }); diff --git a/test/unit/lib/createProxyCache.test.ts b/test/unit/lib/createProxyCache.test.ts index 41d44c7..c16bb6d 100644 --- a/test/unit/lib/createProxyCache.test.ts +++ b/test/unit/lib/createProxyCache.test.ts @@ -27,7 +27,7 @@ import { createProxyCache } from '#src/lib'; import { RedisProxyCache } from '#src/lib/storages'; import { ProxyCacheError, ValidationError } from '#src/lib/errors'; import { STORAGE_TYPES } from '#src/constants'; -import { ProxyCacheConfig, StorageType, BasicConnectionConfig } from '#src/types'; +import { ProxyCacheConfig, StorageType, IProxyCache, BasicConnectionConfig } from '#src/types'; import * as fixtures from '#test/fixtures'; @@ -43,23 +43,48 @@ describe('createProxyCache Tests -->', () => { expect(() => createProxyCache(STORAGE_TYPES.redis)).toThrow(ValidationError); }); - test('should create RedisProxyCache instance', () => { - const proxyCache = createProxyCache(STORAGE_TYPES.redis, fixtures.redisProxyConfigDto()); - expect(proxyCache).toBeInstanceOf(RedisProxyCache); - }); - - test('should use lazyConnect=true option by default', () => { - const { cluster } = fixtures.redisProxyConfigDto(); - const proxyCache = createProxyCache(STORAGE_TYPES.redis, { cluster }); + const checkDefaultLazyConnectOptionUseCase = (proxyCache: IProxyCache) => { // @ts-expect-error TS7053: Element implicitly has an any type because expression of type 'redisClient' can't be used to index type IProxyCache const { options } = proxyCache['redisClient']; expect(options.lazyConnect).toBe(true); + }; + + describe('Redis Standalone Tests -->', () => { + test('should create RedisProxyCache instance', () => { + const proxyCache = createProxyCache(STORAGE_TYPES.redis, fixtures.redisProxyConfigDto()); + expect(proxyCache).toBeInstanceOf(RedisProxyCache); + }); + + test('should use lazyConnect=true option by default', () => { + const { host, port } = fixtures.redisProxyConfigDto(); + const proxyCache = createProxyCache(STORAGE_TYPES.redis, { host, port }); + checkDefaultLazyConnectOptionUseCase(proxyCache); + }); + + test('should fail if host is string', () => { + // prettier-ignore + expect(() => createProxyCache(STORAGE_TYPES.redis, { host: '', port: 123 })) + .toThrow(ValidationError); + }); }); - test('should fail if cluster array is empty', () => { - const cluster: BasicConnectionConfig[] = []; - // prettier-ignore - expect(() => createProxyCache(STORAGE_TYPES.redis, { cluster })) - .toThrow(ValidationError); + describe('Redis Cluster Tests -->', () => { + test('should create RedisProxyCache instance with cluster support', () => { + const proxyCache = createProxyCache(STORAGE_TYPES.redisCluster, fixtures.redisClusterProxyConfigDto()); + expect(proxyCache).toBeInstanceOf(RedisProxyCache); + }); + + test('should use lazyConnect=true option by default for cluster', () => { + const { cluster } = fixtures.redisClusterProxyConfigDto(); + const proxyCache = createProxyCache(STORAGE_TYPES.redisCluster, { cluster }); + checkDefaultLazyConnectOptionUseCase(proxyCache); + }); + + test('should fail if cluster array is empty', () => { + const cluster: BasicConnectionConfig[] = []; + // prettier-ignore + expect(() => createProxyCache(STORAGE_TYPES.redis, { cluster })) + .toThrow(ValidationError); + }); }); }); diff --git a/test/unit/lib/storages/RedisClusterProxyCache.test.ts b/test/unit/lib/storages/RedisClusterProxyCache.test.ts new file mode 100644 index 0000000..713c55b --- /dev/null +++ b/test/unit/lib/storages/RedisClusterProxyCache.test.ts @@ -0,0 +1,166 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files 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, the Mojaloop files are 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. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +import { IoRedisMock } from '../../mocks'; +jest.mock('ioredis', () => IoRedisMock); + +import { createProxyCache, IProxyCache, STORAGE_TYPES } from '#src/index'; +import { RedisProxyCache } from '#src/lib/storages'; +import { ValidationError } from '#src/lib/errors'; + +import * as useCases from '#test/useCases'; +import * as fixtures from '#test/fixtures'; + +const redisClusterProxyConfig = fixtures.redisClusterProxyConfigDto(); + +describe('RedisClusterProxyCache Tests -->', () => { + const { cluster, ...redisOptions } = redisClusterProxyConfig; + const redisClient = new IoRedisMock.Cluster(cluster, { redisOptions }); + + let proxyCache: IProxyCache; + let anotherProxyCache: IProxyCache; + + beforeAll(async () => { + proxyCache = createProxyCache(STORAGE_TYPES.redisCluster, redisClusterProxyConfig); + anotherProxyCache = createProxyCache(STORAGE_TYPES.redisCluster, redisClusterProxyConfig); + // prettier-ignore + await Promise.any([ + proxyCache.connect(), + anotherProxyCache.connect(), + redisClient.connect() + ]); + expect(proxyCache.isConnected).toBe(true); + }); + + afterAll(async () => { + // prettier-ignore + await Promise.all([ + proxyCache?.disconnect(), + anotherProxyCache.disconnect(), + redisClient?.quit(), + ]); + }); + + describe('Use cases Tests -->', () => { + test('should add/get/remove dfspId to proxyMapping', async () => { + const isPassed = await useCases.proxyMappingUseCase(proxyCache); + expect(isPassed).toBe(true); + }); + + test('should detect final errorCallback response', async () => { + const isPassed = await useCases.detectFinalErrorCallbackUseCase(proxyCache); + expect(isPassed).toBe(true); + }); + + test('should set the same proxiesList only once', async () => { + const isPassed = await useCases.setSendToProxiesListOnceUseCase(proxyCache); + expect(isPassed).toBe(true); + }); + + test('should NOT set another proxiesList for the same ALS request (sourceId/type/partyId)', async () => { + const isPassed = await useCases.notSetSendToProxiesListForTheSameAlsRequestUseCase(proxyCache); + expect(isPassed).toBe(true); + }); + + test('should have shared db info for all connected instances', async () => { + await useCases.shareDbInfoForAllConnectedInstances(proxyCache, anotherProxyCache); + }); + }); + + describe('setSendToProxiesList Method Tests -->', () => { + test('should set proxiesList', async () => { + const alsReq = fixtures.alsRequestDetailsDto(); + const proxyIds = ['proxy1', 'proxy2']; + const isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); + expect(isOk).toBe(true); + }); + + test('should set proxiesList with proper TTL', async () => { + const alsReq = fixtures.alsRequestDetailsDto(); + const proxyIds = ['proxy1', 'proxy2']; + const ttlSec = 1; + const isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, ttlSec); + expect(isOk).toBe(true); + + const key = RedisProxyCache.formatAlsCacheKey(alsReq); + let rawExistsResult = await redisClient.exists(key); + expect(rawExistsResult).toBe(1); + + await new Promise((resolve) => setTimeout(resolve, ttlSec * 1000)); + + rawExistsResult = await redisClient.exists(key); + expect(rawExistsResult).toBe(0); + }); + + test('should throw validation error if alsRequest is invalid', async () => { + expect(() => { + RedisProxyCache.formatAlsCacheKey({} as any); + }).toThrow(ValidationError); + }); + }); + + describe('receivedSuccessResponse Method Tests -->', () => { + test('should delete sendToProxiesList on receivedSuccessResponse call', async () => { + const alsReq = fixtures.alsRequestDetailsDto(); + const proxyIds = ['proxy1', 'proxy2']; + const isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 2); + expect(isOk).toBe(true); + + const key = RedisProxyCache.formatAlsCacheKey(alsReq); + let rawExistsResult = await redisClient.exists(key); + expect(rawExistsResult).toBe(1); + + const isDeleted = await proxyCache.receivedSuccessResponse(alsReq); + expect(isDeleted).toBe(true); + + rawExistsResult = await redisClient.exists(key); + expect(rawExistsResult).toBe(0); + }); + + test('should delete sendToProxiesList only once', async () => { + const alsReq = fixtures.alsRequestDetailsDto(); + await proxyCache.setSendToProxiesList(alsReq, ['proxyX'], 2); + + let isDeleted = await proxyCache.receivedSuccessResponse(alsReq); + expect(isDeleted).toBe(true); + + isDeleted = await proxyCache.receivedSuccessResponse(alsReq); + expect(isDeleted).toBe(false); + }); + }); + + describe('addDfspIdToProxyMapping Method Tests -->', () => { + test('should throw validation error if dfspId is invalid', async () => { + // prettier-ignore + await expect(() => proxyCache.addDfspIdToProxyMapping('', 'proxy1')) + .rejects.toThrow(ValidationError); + }); + }); + + test('should have healthCheck method', async () => { + const isOk = await proxyCache.healthCheck(); + expect(isOk).toBe(true); + }); +}); diff --git a/test/unit/lib/storages/RedisProxyCache.test.ts b/test/unit/lib/storages/RedisProxyCache.test.ts index a2b676c..6ff2774 100644 --- a/test/unit/lib/storages/RedisProxyCache.test.ts +++ b/test/unit/lib/storages/RedisProxyCache.test.ts @@ -36,25 +36,29 @@ import * as fixtures from '#test/fixtures'; const redisProxyConfig = fixtures.redisProxyConfigDto(); describe('RedisProxyCache Tests -->', () => { - const { cluster, ...redisOptions } = redisProxyConfig; - const redisClient = new IoRedisMock.Cluster(cluster, { redisOptions }); + const redisClient = new IoRedisMock(redisProxyConfig); let proxyCache: IProxyCache; + let anotherProxyCache: IProxyCache; - beforeEach(async () => { + beforeAll(async () => { proxyCache = createProxyCache(STORAGE_TYPES.redis, redisProxyConfig); + anotherProxyCache = createProxyCache(STORAGE_TYPES.redis, redisProxyConfig); + // prettier-ignore await Promise.any([ proxyCache.connect(), - redisClient.connect() + anotherProxyCache.connect(), + redisClient.connect(), ]); expect(proxyCache.isConnected).toBe(true); }); - afterEach(async () => { + afterAll(async () => { // prettier-ignore await Promise.all([ proxyCache?.disconnect(), + anotherProxyCache.disconnect(), redisClient?.quit(), ]); }); @@ -69,32 +73,28 @@ describe('RedisProxyCache Tests -->', () => { const isPassed = await useCases.detectFinalErrorCallbackUseCase(proxyCache); expect(isPassed).toBe(true); }); - }); - - describe('setSendToProxiesList Method Tests -->', () => { - test('should set proxiesList', async () => { - const alsReq = fixtures.alsRequestDetailsDto(); - const proxyIds = ['proxy1', 'proxy2']; - const isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); - expect(isOk).toBe(true); - }); test('should set the same proxiesList only once', async () => { - const alsReq = fixtures.alsRequestDetailsDto(); - const proxyIds = ['proxy1', 'proxy2']; - let isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); - expect(isOk).toBe(true); - isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); - expect(isOk).toBe(false); + const isPassed = await useCases.setSendToProxiesListOnceUseCase(proxyCache); + expect(isPassed).toBe(true); }); test('should NOT set another proxiesList for the same ALS request (sourceId/type/partyId)', async () => { + const isPassed = await useCases.notSetSendToProxiesListForTheSameAlsRequestUseCase(proxyCache); + expect(isPassed).toBe(true); + }); + + test('should have shared db info for all connected instances', async () => { + await useCases.shareDbInfoForAllConnectedInstances(proxyCache, anotherProxyCache); + }); + }); + + describe('setSendToProxiesList Method Tests -->', () => { + test('should set proxiesList', async () => { const alsReq = fixtures.alsRequestDetailsDto(); const proxyIds = ['proxy1', 'proxy2']; - let isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); + const isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); expect(isOk).toBe(true); - isOk = await proxyCache.setSendToProxiesList(alsReq, ['proxyA'], 1); - expect(isOk).toBe(false); }); test('should set proxiesList with proper TTL', async () => { diff --git a/test/useCases.ts b/test/useCases.ts index ab8cdde..33504a1 100644 --- a/test/useCases.ts +++ b/test/useCases.ts @@ -76,6 +76,37 @@ export const detectFinalErrorCallbackUseCase = async (proxyCache: IProxyCache) = return true; }; +export const setSendToProxiesListOnceUseCase = async (proxyCache: IProxyCache) => { + const alsReq = fixtures.alsRequestDetailsDto(); + const proxyIds = [`proxy1-${Date.now()}`, `proxy2-${Date.now()}`]; + let isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); + expect(isOk).toBe(true); + isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); + expect(isOk).toBe(false); + return true; +}; + +export const notSetSendToProxiesListForTheSameAlsRequestUseCase = async (proxyCache: IProxyCache) => { + const alsReq = fixtures.alsRequestDetailsDto(); + const proxyIds = ['proxy123', 'proxy098']; + let isOk = await proxyCache.setSendToProxiesList(alsReq, proxyIds, 1); + expect(isOk).toBe(true); + isOk = await proxyCache.setSendToProxiesList(alsReq, ['proxyAB'], 1); + expect(isOk).toBe(false); + return true; +}; + +export const shareDbInfoForAllConnectedInstances = async (proxyCache: IProxyCache, anotherProxyCache: IProxyCache) => { + const alsReq = fixtures.alsRequestDetailsDto(); + const proxyId = 'proxyXZ'; + + const isOk = await proxyCache.setSendToProxiesList(alsReq, [proxyId], 1); + expect(isOk).toBe(true); + + const isLast = await anotherProxyCache.receivedErrorResponse(alsReq, proxyId); + expect(isLast).toBe(true); +}; + function randomIntSting(): string { return String(Date.now()).substring(9); }