@mojaloop/central-services-shared 18.23.3 → 18.24.0-snapshot.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojaloop/central-services-shared",
3
- "version": "18.23.3",
3
+ "version": "18.24.0-snapshot.2",
4
4
  "description": "Shared code for mojaloop central services",
5
5
  "license": "Apache-2.0",
6
6
  "author": "ModusBox",
package/src/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Utils as HapiUtil, Server } from '@hapi/hapi'
2
2
  import { ILogger } from '@mojaloop/central-services-logger/src/contextLogger'
3
+ import IORedis from 'ioredis';
3
4
 
4
5
  declare namespace CentralServicesShared {
5
6
  interface ReturnCode {
@@ -663,7 +664,7 @@ declare namespace CentralServicesShared {
663
664
  type ProtocolResources = string[]
664
665
  type ProtocolVersions = (string | symbol)[]
665
666
  type ApiTypeValues = 'fspiop' | 'iso20022'
666
- type APIDocumentationPluginOptions =
667
+ type APIDocumentationPluginOptions =
667
668
  | { documentPath: string; document?: never }
668
669
  | { document?: string; documentPath?: never }
669
670
 
@@ -707,7 +708,7 @@ declare namespace CentralServicesShared {
707
708
  plugin: {
708
709
  name: string,
709
710
  register: (server: Server) => void
710
- }
711
+ }
711
712
  };
712
713
  customCurrencyCodeValidation: (joi: any) => {
713
714
  base: any;
@@ -735,6 +736,37 @@ declare namespace CentralServicesShared {
735
736
  }
736
737
  // todo: define the rest of the types
737
738
 
739
+ interface PubSub {
740
+ (config: object, publisherClient?: IORedis, subscriberClient?: IORedis): PubSub;
741
+ new (config: object, publisherClient: IORedis, subscriberClient: IORedis): PubSub;
742
+ connect(): Promise<void>;
743
+ disconnect(): Promise<boolean>;
744
+ healthCheck(): Promise<boolean>;
745
+ isConnected: { publisherConnected: boolean; subscriberConnected: boolean };
746
+ publish(channel: string, message: any): Promise<void>;
747
+ subscribe(channel: string, callback: (message: any) => void): Promise<string>;
748
+ unsubscribe(channel: string): Promise<void>;
749
+ broadcast(channels: string[], message: any): Promise<void>;
750
+ }
751
+
752
+ interface RedisCache {
753
+ (config: object, client?: IORedis): RedisCache;
754
+ new (config: object, client?: IORedis): RedisCache;
755
+ connect(): Promise<boolean>;
756
+ disconnect(): Promise<boolean>;
757
+ healthCheck(): Promise<boolean>;
758
+ isConnected: boolean;
759
+ get(key: string): Promise<string | null>;
760
+ set(key: string, value: string, ttl?: number): Promise<void>;
761
+ delete(key: string): Promise<void>;
762
+ clearCache(): Promise<void>;
763
+ }
764
+
765
+ interface Redis {
766
+ PubSub: PubSub;
767
+ Cache: RedisCache;
768
+ }
769
+
738
770
  interface Util {
739
771
  Endpoints: Endpoints;
740
772
  Participants: Participants;
@@ -745,6 +777,7 @@ declare namespace CentralServicesShared {
745
777
  Request: Request;
746
778
  StreamingProtocol: StreamingProtocol;
747
779
  HeaderValidation: HeaderValidation;
780
+ Redis: Redis;
748
781
  }
749
782
 
750
783
  const Enum: Enum
@@ -0,0 +1,37 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2020-2025 Mojaloop Foundation
5
+ The Mojaloop files are made available by the Mojaloop 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
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ 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.
10
+
11
+ Contributors
12
+ --------------
13
+ This is the official list of the Mojaloop project contributors for this file.
14
+ Names of the original copyright holders (individuals or organizations)
15
+ should be listed with a '*' in the first column. People who have
16
+ contributed from an organization can be listed under the organization
17
+ that actually holds the copyright for their contributions (see the
18
+ Mojaloop Foundation for an example). Those individuals should have
19
+ their names indented and be marked with a '-'. Email address can be added
20
+ optionally within square brackets <email>.
21
+
22
+ * Mojaloop Foundation
23
+ - Name Surname <name.surname@mojaloop.io>
24
+
25
+ * Kevin Leyow <kevin.leyow@infitx.com>
26
+
27
+ --------------
28
+ ******/
29
+
30
+ const { logger } = require('../logger')
31
+ const config = require('../config')
32
+
33
+ exports.createLogger = (component) => {
34
+ const log = logger.child({ component })
35
+ log.setLevel(config.get('logLevel'))
36
+ return log
37
+ }
package/src/util/index.js CHANGED
@@ -31,8 +31,6 @@
31
31
  'use strict'
32
32
 
33
33
  const _ = require('lodash')
34
- const { logger } = require('../logger')
35
- const config = require('../config')
36
34
 
37
35
  const Kafka = require('./kafka')
38
36
  const Endpoints = require('./endpoints')
@@ -55,6 +53,8 @@ const Schema = require('./schema')
55
53
  const OpenapiBackend = require('./openapiBackend')
56
54
  const id = require('./id')
57
55
  const rethrow = require('./rethrow')
56
+ const Redis = require('./redis')
57
+ const createLogger = require('./createLogger')
58
58
 
59
59
  const omitNil = (object) => {
60
60
  return _.omitBy(object, _.isNil)
@@ -226,12 +226,6 @@ const filterExtensions = (extensionsArray, exclKeysArray, exclValuesArray) => {
226
226
  })
227
227
  }
228
228
 
229
- const createLogger = (component) => {
230
- const log = logger.child({ component })
231
- log.setLevel(config.get('logLevel'))
232
- return log
233
- }
234
-
235
229
  module.exports = {
236
230
  createLogger,
237
231
  assign,
@@ -272,5 +266,6 @@ module.exports = {
272
266
  OpenapiBackend,
273
267
  id,
274
268
  resourceVersions: Helpers.resourceVersions,
275
- rethrow
269
+ rethrow,
270
+ Redis
276
271
  }
@@ -0,0 +1,36 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2020-2025 Mojaloop Foundation
5
+ The Mojaloop files are made available by the Mojaloop 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
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ 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.
10
+
11
+ Contributors
12
+ --------------
13
+ This is the official list of the Mojaloop project contributors for this file.
14
+ Names of the original copyright holders (individuals or organizations)
15
+ should be listed with a '*' in the first column. People who have
16
+ contributed from an organization can be listed under the organization
17
+ that actually holds the copyright for their contributions (see the
18
+ Mojaloop Foundation for an example). Those individuals should have
19
+ their names indented and be marked with a '-'. Email address can be added
20
+ optionally within square brackets <email>.
21
+
22
+ * Mojaloop Foundation
23
+ - Name Surname <name.surname@mojaloop.io>
24
+
25
+ * Kevin Leyow <kevin.leyow@infitx.com>
26
+
27
+ --------------
28
+ ******/
29
+
30
+ const RedisCache = require('./redisCache')
31
+ const PubSub = require('./pubSub')
32
+
33
+ module.exports = {
34
+ RedisCache,
35
+ PubSub
36
+ }
@@ -0,0 +1,163 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2020-2025 Mojaloop Foundation
5
+ The Mojaloop files are made available by the Mojaloop 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
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ 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.
10
+
11
+ Contributors
12
+ --------------
13
+ This is the official list of the Mojaloop project contributors for this file.
14
+ Names of the original copyright holders (individuals or organizations)
15
+ should be listed with a '*' in the first column. People who have
16
+ contributed from an organization can be listed under the organization
17
+ that actually holds the copyright for their contributions (see the
18
+ Mojaloop Foundation for an example). Those individuals should have
19
+ their names indented and be marked with a '-'. Email address can be added
20
+ optionally within square brackets <email>.
21
+
22
+ * Mojaloop Foundation
23
+ - Name Surname <name.surname@mojaloop.io>
24
+
25
+ * Kevin Leyow <kevin.leyow@infitx.com>
26
+
27
+ --------------
28
+ ******/
29
+
30
+ 'use strict'
31
+ const Redis = require('ioredis')
32
+ const { createLogger } = require('../createLogger')
33
+ const isClusterConfig = (config) => { return 'cluster' in config }
34
+ const { rethrowRedisError } = require('../rethrow')
35
+ const { REDIS_SUCCESS, REDIS_IS_CONNECTED_STATUSES } = require('../../constants')
36
+
37
+ class PubSub {
38
+ constructor (config, publisherClient, subscriberClient) {
39
+ // prepare redis connection instances
40
+ // once the client enters the subscribed state it is not supposed to issue
41
+ // any other commands, except for additional SUBSCRIBE, PSUBSCRIBE,
42
+ // UNSUBSCRIBE, PUNSUBSCRIBE, PING and QUIT commands.
43
+ // So we create two clients, one for subscribing another for
44
+ // and publishing
45
+ this.config = config
46
+ this.isCluster = isClusterConfig(config)
47
+ this.log = createLogger(this.constructor.name)
48
+ this.publisherClient = publisherClient || this.createRedisClient()
49
+ this.subscriberClient = subscriberClient || this.createRedisClient()
50
+ this.addEventListeners(this.publisherClient)
51
+ this.addEventListeners(this.subscriberClient)
52
+ }
53
+
54
+ createRedisClient () {
55
+ this.config.lazyConnect ??= true
56
+ return this.isCluster
57
+ ? new Redis.Cluster(this.config.cluster, this.config)
58
+ : new Redis(this.config)
59
+ }
60
+
61
+ async connect () {
62
+ try {
63
+ await this.publisherClient.connect()
64
+ await this.subscriberClient.connect()
65
+ this.log.info('Redis clients connected successfully')
66
+ } catch (err) {
67
+ this.log.error('Error connecting Redis clients:', err)
68
+ rethrowRedisError(err)
69
+ }
70
+ }
71
+
72
+ async disconnect () {
73
+ try {
74
+ const publisherResponse = await this.publisherClient.quit()
75
+ const subscriberResponse = await this.subscriberClient.quit()
76
+ const isDisconnected = publisherResponse === REDIS_SUCCESS && subscriberResponse === REDIS_SUCCESS
77
+ this.subscriberClient.removeAllListeners()
78
+ this.log.info('Redis clients disconnected successfully')
79
+ return isDisconnected
80
+ } catch (err) {
81
+ this.log.error('Error disconnecting Redis clients:', err)
82
+ rethrowRedisError(err)
83
+ }
84
+ }
85
+
86
+ async healthCheck () {
87
+ try {
88
+ const publisherStatus = await this.publisherClient.ping()
89
+ const subscriberStatus = await this.subscriberClient.ping()
90
+ const isHealthy = publisherStatus === 'PONG' && subscriberStatus === 'PONG'
91
+ this.log.debug(`Redis health check: ${isHealthy ? 'Healthy' : 'Unhealthy'}`)
92
+ return isHealthy
93
+ } catch (err) {
94
+ this.log.error('Error performing Redis health check:', err)
95
+ return false
96
+ }
97
+ }
98
+
99
+ get isConnected () {
100
+ const publisherConnected = REDIS_IS_CONNECTED_STATUSES.includes(this.publisherClient.status)
101
+ const subscriberConnected = REDIS_IS_CONNECTED_STATUSES.includes(this.subscriberClient.status)
102
+ this.log.debug('Redis connection status', {
103
+ publisherConnected,
104
+ subscriberConnected
105
+ })
106
+ return { publisherConnected, subscriberConnected }
107
+ }
108
+
109
+ addEventListeners (client) {
110
+ client.on('connect', () => this.log.info('Redis client connected'))
111
+ client.on('error', (err) => this.log.error('Redis client error:', err))
112
+ }
113
+
114
+ async publish (channel, message) {
115
+ try {
116
+ await this.publisherClient.publish(channel, JSON.stringify(message))
117
+ this.log.info(`Message published to channel: ${channel}`)
118
+ } catch (err) {
119
+ this.log.error('Error publishing message:', err)
120
+ rethrowRedisError(err)
121
+ }
122
+ }
123
+
124
+ async subscribe (channel, callback) {
125
+ try {
126
+ await this.subscriberClient.subscribe(channel)
127
+ this.subscriberClient.on('message', (subscribedChannel, message) => {
128
+ if (subscribedChannel === channel) {
129
+ callback(JSON.parse(message))
130
+ }
131
+ })
132
+ this.log.info(`Subscribed to channel: ${channel}`)
133
+ return channel
134
+ } catch (err) {
135
+ this.log.error('Error subscribing to channel:', err)
136
+ rethrowRedisError(err)
137
+ }
138
+ }
139
+
140
+ async unsubscribe (channel) {
141
+ try {
142
+ await this.subscriberClient.unsubscribe(channel)
143
+ this.log.info(`Unsubscribed from channel: ${channel}`)
144
+ } catch (err) {
145
+ this.log.error('Error unsubscribing from channel:', err)
146
+ rethrowRedisError(err)
147
+ }
148
+ }
149
+
150
+ async broadcast (channels, message) {
151
+ try {
152
+ for (const channel of channels) {
153
+ await this.publish(channel, message)
154
+ }
155
+ this.log.info(`Message broadcasted to channels: ${channels.join(', ')}`)
156
+ } catch (err) {
157
+ this.log.error('Error broadcasting message:', err)
158
+ rethrowRedisError(err)
159
+ }
160
+ }
161
+ }
162
+
163
+ module.exports = PubSub
@@ -1,5 +1,34 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2020-2025 Mojaloop Foundation
5
+ The Mojaloop files are made available by the Mojaloop 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
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ 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.
10
+
11
+ Contributors
12
+ --------------
13
+ This is the official list of the Mojaloop project contributors for this file.
14
+ Names of the original copyright holders (individuals or organizations)
15
+ should be listed with a '*' in the first column. People who have
16
+ contributed from an organization can be listed under the organization
17
+ that actually holds the copyright for their contributions (see the
18
+ Mojaloop Foundation for an example). Those individuals should have
19
+ their names indented and be marked with a '-'. Email address can be added
20
+ optionally within square brackets <email>.
21
+
22
+ * Mojaloop Foundation
23
+ - Name Surname <name.surname@mojaloop.io>
24
+
25
+ * Kevin Leyow <kevin.leyow@infitx.com>
26
+
27
+ --------------
28
+ ******/
29
+
1
30
  const Redis = require('ioredis')
2
- const { createLogger } = require('../index')
31
+ const { createLogger } = require('../createLogger')
3
32
  const { REDIS_SUCCESS, REDIS_IS_CONNECTED_STATUSES } = require('../../constants')
4
33
  const isClusterConfig = (config) => { return 'cluster' in config }
5
34
  const { rethrowRedisError } = require('../rethrow')
@@ -0,0 +1,375 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2020-2025 Mojaloop Foundation
5
+ The Mojaloop files are made available by the Mojaloop 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
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ 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.
10
+
11
+ Contributors
12
+ --------------
13
+ This is the official list of the Mojaloop project contributors for this file.
14
+ Names of the original copyright holders (individuals or organizations)
15
+ should be listed with a '*' in the first column. People who have
16
+ contributed from an organization can be listed under the organization
17
+ that actually holds the copyright for their contributions (see the
18
+ Mojaloop Foundation for an example). Those individuals should have
19
+ their names indented and be marked with a '-'. Email address can be added
20
+ optionally within square brackets <email>.
21
+
22
+ * Mojaloop Foundation
23
+ - Name Surname <name.surname@mojaloop.io>
24
+
25
+ * Kevin Leyow <kevin.leyow@modusbox.com>
26
+
27
+ --------------
28
+ ******/
29
+ const Test = require('tapes')(require('tape'))
30
+ const sinon = require('sinon')
31
+ const Redis = require('ioredis')
32
+ const PubSub = require('../../../../src/util/redis/pubSub')
33
+ const { constructSystemExtensionError } = require('../../../../src/util/rethrow')
34
+
35
+ Test('PubSub', (t) => {
36
+ let sandbox
37
+
38
+ t.beforeEach((t) => {
39
+ sandbox = sinon.createSandbox()
40
+ sandbox.stub(Redis.prototype, 'publish')
41
+ sandbox.stub(Redis.prototype, 'subscribe')
42
+ sandbox.stub(Redis.prototype, 'unsubscribe')
43
+ sandbox.stub(Redis.prototype, 'on')
44
+ sandbox.stub(Redis.Cluster.prototype, 'on')
45
+ t.end()
46
+ })
47
+
48
+ t.afterEach((t) => {
49
+ sandbox.restore()
50
+ t.end()
51
+ })
52
+
53
+ t.test('should create a Redis client and subscriber', (t) => {
54
+ const config = { lazyConnect: true }
55
+ const pubSub = new PubSub(config)
56
+ t.ok(pubSub.publisherClient instanceof Redis, 'publisherClient is an instance of Redis')
57
+ t.ok(pubSub.subscriberClient instanceof Redis, 'subscriberClient is an instance of Redis')
58
+ t.end()
59
+ })
60
+
61
+ t.test('should publish a message to a channel', async (t) => {
62
+ const config = {}
63
+ const pubSub = new PubSub(config)
64
+ const channel = 'test-channel'
65
+ const message = { key: 'value' }
66
+ await pubSub.publish(channel, message)
67
+
68
+ t.ok(pubSub.publisherClient.publish.calledWith(channel, JSON.stringify(message)), 'publish called with correct arguments')
69
+ t.end()
70
+ })
71
+
72
+ t.test('should handle error when publishing a message', async (t) => {
73
+ const config = {}
74
+ const pubSub = new PubSub(config)
75
+ const channel = 'test-channel'
76
+ const message = { key: 'value' }
77
+ const error = new Error('Publish error')
78
+
79
+ pubSub.publisherClient.publish.rejects(error)
80
+
81
+ try {
82
+ await pubSub.publish(channel, message)
83
+ t.fail('Should have thrown an error')
84
+ } catch (err) {
85
+ t.deepEqual(err, constructSystemExtensionError(error, '["redis"]'), 'Error thrown and rethrown correctly')
86
+ }
87
+ t.end()
88
+ })
89
+
90
+ t.test('should subscribe to a channel and handle messages', async (t) => {
91
+ const config = {}
92
+ const pubSub = new PubSub(config)
93
+ const channel = 'test-channel'
94
+ const callback = sinon.stub()
95
+ const message = JSON.stringify({ key: 'value' })
96
+
97
+ await pubSub.subscribe(channel, callback)
98
+ pubSub.subscriberClient.on.callArgWith(1, channel, message)
99
+
100
+ t.ok(pubSub.subscriberClient.subscribe.calledWith(channel), 'subscribe called with correct channel')
101
+ t.ok(callback.calledWith(JSON.parse(message)), 'callback called with parsed message')
102
+ t.end()
103
+ })
104
+
105
+ t.test('should handle error when subscribing to a channel', async (t) => {
106
+ const config = {}
107
+ const pubSub = new PubSub(config)
108
+ const channel = 'test-channel'
109
+ const callback = sinon.stub()
110
+ const error = new Error('Subscribe error')
111
+
112
+ pubSub.subscriberClient.subscribe.rejects(error)
113
+
114
+ try {
115
+ await pubSub.subscribe(channel, callback)
116
+ t.fail('Should have thrown an error')
117
+ } catch (err) {
118
+ t.deepEqual(err, constructSystemExtensionError(error, '["redis"]'), 'Error thrown and rethrown correctly')
119
+ }
120
+ t.end()
121
+ })
122
+
123
+ t.test('should unsubscribe from a channel', async (t) => {
124
+ const config = {}
125
+ const pubSub = new PubSub(config)
126
+ const channel = 'test-channel'
127
+
128
+ await pubSub.unsubscribe(channel)
129
+
130
+ t.ok(pubSub.subscriberClient.unsubscribe.calledWith(channel), 'unsubscribe called with correct channel')
131
+ t.end()
132
+ })
133
+
134
+ t.test('should handle error when unsubscribing from a channel', async (t) => {
135
+ const config = {}
136
+ const pubSub = new PubSub(config)
137
+ const channel = 'test-channel'
138
+ const error = new Error('Unsubscribe error')
139
+
140
+ pubSub.subscriberClient.unsubscribe.rejects(error)
141
+
142
+ try {
143
+ await pubSub.unsubscribe(channel)
144
+ t.fail('Should have thrown an error')
145
+ } catch (err) {
146
+ t.deepEqual(err, constructSystemExtensionError(error, '["redis"]'), 'Error thrown and rethrown correctly')
147
+ }
148
+ t.end()
149
+ })
150
+
151
+ t.test('should broadcast a message to multiple channels', async (t) => {
152
+ const config = {}
153
+ const pubSub = new PubSub(config)
154
+ const channels = ['channel1', 'channel2']
155
+ const message = { key: 'value' }
156
+
157
+ await pubSub.broadcast(channels, message)
158
+
159
+ t.ok(pubSub.publisherClient.publish.calledTwice, 'publish called twice')
160
+ t.ok(pubSub.publisherClient.publish.firstCall.calledWith(channels[0], JSON.stringify(message)), 'publish called with first channel and message')
161
+ t.ok(pubSub.publisherClient.publish.secondCall.calledWith(channels[1], JSON.stringify(message)), 'publish called with second channel and message')
162
+ t.end()
163
+ })
164
+
165
+ t.test('should handle error when broadcasting a message', async (t) => {
166
+ const config = {}
167
+ const pubSub = new PubSub(config)
168
+ const channels = ['channel1', 'channel2']
169
+ const message = { key: 'value' }
170
+ const error = new Error('Broadcast error')
171
+
172
+ pubSub.publisherClient.publish.onFirstCall().rejects(error)
173
+
174
+ try {
175
+ await pubSub.broadcast(channels, message)
176
+ t.fail('Should have thrown an error')
177
+ } catch (err) {
178
+ t.deepEqual(err, constructSystemExtensionError(error, '["redis"]'), 'Error thrown and rethrown correctly')
179
+ }
180
+ t.end()
181
+ })
182
+
183
+ t.test('should connect Redis clients successfully', async (t) => {
184
+ const config = {}
185
+ const pubSub = new PubSub(config)
186
+
187
+ sandbox.stub(pubSub.publisherClient, 'connect').resolves()
188
+ sandbox.stub(pubSub.subscriberClient, 'connect').resolves()
189
+
190
+ await pubSub.connect()
191
+
192
+ t.ok(pubSub.publisherClient.connect.calledOnce, 'publisherClient connect called once')
193
+ t.ok(pubSub.subscriberClient.connect.calledOnce, 'subscriberClient connect called once')
194
+ t.end()
195
+ })
196
+
197
+ t.test('should handle error when connecting Redis clients', async (t) => {
198
+ const config = {}
199
+ const pubSub = new PubSub(config)
200
+ const error = new Error('Connect error')
201
+
202
+ sandbox.stub(pubSub.publisherClient, 'connect').rejects(error)
203
+ sandbox.stub(pubSub.subscriberClient, 'connect').resolves()
204
+
205
+ try {
206
+ await pubSub.connect()
207
+ t.fail('Should have thrown an error')
208
+ } catch (err) {
209
+ t.deepEqual(err, constructSystemExtensionError(error, '["redis"]'), 'Error thrown and rethrown correctly')
210
+ }
211
+ t.end()
212
+ })
213
+
214
+ t.test('should create a Redis Cluster client when cluster config is provided', (t) => {
215
+ const config = { cluster: [{ host: '127.0.0.1', port: 6379 }] }
216
+ const pubSub = new PubSub(config)
217
+
218
+ t.ok(pubSub.publisherClient instanceof Redis.Cluster, 'publisherClient is an instance of Redis.Cluster')
219
+ t.ok(pubSub.subscriberClient instanceof Redis.Cluster, 'subscriberClient is an instance of Redis.Cluster')
220
+ t.end()
221
+ })
222
+
223
+ t.test('should connect Redis Cluster clients successfully', async (t) => {
224
+ const config = { cluster: [{ host: '127.0.0.1', port: 6379 }] }
225
+ const pubSub = new PubSub(config)
226
+
227
+ sandbox.stub(pubSub.publisherClient, 'connect').resolves()
228
+ sandbox.stub(pubSub.subscriberClient, 'connect').resolves()
229
+
230
+ await pubSub.connect()
231
+
232
+ t.ok(pubSub.publisherClient.connect.calledOnce, 'publisherClient connect called once')
233
+ t.ok(pubSub.subscriberClient.connect.calledOnce, 'subscriberClient connect called once')
234
+ t.end()
235
+ })
236
+
237
+ t.test('should handle error when connecting Redis Cluster clients', async (t) => {
238
+ const config = { cluster: [{ host: '127.0.0.1', port: 6379 }] }
239
+ const pubSub = new PubSub(config)
240
+ const error = new Error('Cluster connect error')
241
+
242
+ sandbox.stub(pubSub.publisherClient, 'connect').rejects(error)
243
+ sandbox.stub(pubSub.subscriberClient, 'connect').resolves()
244
+
245
+ try {
246
+ await pubSub.connect()
247
+ t.fail('Should have thrown an error')
248
+ } catch (err) {
249
+ t.deepEqual(err, constructSystemExtensionError(error, '["redis"]'), 'Error thrown and rethrown correctly')
250
+ }
251
+ t.end()
252
+ })
253
+
254
+ t.test('should not call callback if subscribedChannel does not match channel', async (t) => {
255
+ const config = {}
256
+ const pubSub = new PubSub(config)
257
+ const channel = 'test-channel'
258
+ const callback = sinon.stub()
259
+ const message = JSON.stringify({ key: 'value' })
260
+ const otherChannel = 'other-channel'
261
+
262
+ await pubSub.subscribe(channel, callback)
263
+ pubSub.subscriberClient.on.callArgWith(1, otherChannel, message)
264
+
265
+ t.ok(pubSub.subscriberClient.subscribe.calledWith(channel), 'subscribe called with correct channel')
266
+ t.notOk(callback.called, 'callback not called when subscribedChannel does not match channel')
267
+ t.end()
268
+ })
269
+
270
+ t.test('should disconnect Redis clients successfully', async (t) => {
271
+ const config = {}
272
+ const pubSub = new PubSub(config)
273
+
274
+ sandbox.stub(pubSub.publisherClient, 'quit').resolves()
275
+ sandbox.stub(pubSub.subscriberClient, 'quit').resolves()
276
+ sandbox.stub(pubSub.subscriberClient, 'removeAllListeners').resolves()
277
+
278
+ await pubSub.disconnect()
279
+
280
+ t.ok(pubSub.publisherClient.quit.calledOnce, 'publisherClient quit called once')
281
+ t.ok(pubSub.subscriberClient.quit.calledOnce, 'subscriberClient quit called once')
282
+ t.ok(pubSub.subscriberClient.removeAllListeners.calledOnce, 'subscriberClient removeAllListeners called once')
283
+ t.end()
284
+ })
285
+
286
+ t.test('should handle error when disconnecting Redis clients', async (t) => {
287
+ const config = {}
288
+ const pubSub = new PubSub(config)
289
+ const error = new Error('Disconnect error')
290
+
291
+ sandbox.stub(pubSub.publisherClient, 'quit').rejects(error)
292
+ sandbox.stub(pubSub.subscriberClient, 'quit').resolves()
293
+
294
+ try {
295
+ await pubSub.disconnect()
296
+ t.fail('Should have thrown an error')
297
+ } catch (err) {
298
+ t.deepEqual(err, constructSystemExtensionError(error, '["redis"]'), 'Error thrown and rethrown correctly')
299
+ }
300
+ t.end()
301
+ })
302
+
303
+ t.test('should perform health check and return true if both clients are healthy', async (t) => {
304
+ const config = {}
305
+ const pubSub = new PubSub(config)
306
+
307
+ sandbox.stub(pubSub.publisherClient, 'ping').resolves('PONG')
308
+ sandbox.stub(pubSub.subscriberClient, 'ping').resolves('PONG')
309
+
310
+ const isHealthy = await pubSub.healthCheck()
311
+
312
+ t.equal(isHealthy, true, 'healthCheck returns true when both clients are healthy')
313
+ t.ok(pubSub.publisherClient.ping.calledOnce, 'publisherClient ping called once')
314
+ t.ok(pubSub.subscriberClient.ping.calledOnce, 'subscriberClient ping called once')
315
+ t.end()
316
+ })
317
+
318
+ t.test('should perform health check and return false if any client is unhealthy', async (t) => {
319
+ const config = {}
320
+ const pubSub = new PubSub(config)
321
+
322
+ sandbox.stub(pubSub.publisherClient, 'ping').resolves('PONG')
323
+ sandbox.stub(pubSub.subscriberClient, 'ping').resolves('ERROR')
324
+
325
+ const isHealthy = await pubSub.healthCheck()
326
+
327
+ t.equal(isHealthy, false, 'healthCheck returns false when any client is unhealthy')
328
+ t.ok(pubSub.publisherClient.ping.calledOnce, 'publisherClient ping called once')
329
+ t.ok(pubSub.subscriberClient.ping.calledOnce, 'subscriberClient ping called once')
330
+ t.end()
331
+ })
332
+
333
+ t.test('should handle error during health check and return false', async (t) => {
334
+ const config = {}
335
+ const pubSub = new PubSub(config)
336
+ const error = new Error('Health check error')
337
+
338
+ sandbox.stub(pubSub.publisherClient, 'ping').rejects(error)
339
+ sandbox.stub(pubSub.subscriberClient, 'ping').resolves('PONG')
340
+
341
+ const isHealthy = await pubSub.healthCheck()
342
+
343
+ t.equal(isHealthy, false, 'healthCheck returns false when an error occurs')
344
+ t.ok(pubSub.publisherClient.ping.calledOnce, 'publisherClient ping called once')
345
+ t.notOk(pubSub.subscriberClient.ping.calledOnce, 'subscriberClient ping not called once')
346
+ t.end()
347
+ })
348
+
349
+ t.test('should return correct connection statuses for isConnected', (t) => {
350
+ const config = {}
351
+ const pubSub = new PubSub(config)
352
+
353
+ sandbox.stub(pubSub.publisherClient, 'status').value('ready')
354
+ sandbox.stub(pubSub.subscriberClient, 'status').value('ready')
355
+
356
+ const connectionStatus = pubSub.isConnected
357
+
358
+ t.deepEqual(connectionStatus, { publisherConnected: true, subscriberConnected: true }, 'isConnected returns correct statuses')
359
+ t.end()
360
+ })
361
+
362
+ t.test('should return false connection statuses for isConnected when clients are not connected', (t) => {
363
+ const config = {}
364
+ const pubSub = new PubSub(config)
365
+
366
+ sandbox.stub(pubSub.publisherClient, 'status').value('disconnected')
367
+ sandbox.stub(pubSub.subscriberClient, 'status').value('disconnected')
368
+
369
+ const connectionStatus = pubSub.isConnected
370
+
371
+ t.deepEqual(connectionStatus, { publisherConnected: false, subscriberConnected: false }, 'isConnected returns false statuses when clients are not connected')
372
+ t.end()
373
+ })
374
+ t.end()
375
+ })