@app-connect/core 1.7.34 → 1.7.36

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.
Files changed (49) hide show
  1. package/connector/mock.js +9 -4
  2. package/connector/proxy/engine.js +3 -2
  3. package/connector/proxy/index.js +2 -2
  4. package/docs/architecture.md +1 -0
  5. package/docs/handlers.md +1 -1
  6. package/docs/models.md +2 -0
  7. package/docs/routes.md +1 -1
  8. package/handlers/disposition.js +3 -2
  9. package/handlers/log.js +18 -6
  10. package/handlers/plugin.js +13 -6
  11. package/index.js +22 -11
  12. package/lib/callLogLookup.js +82 -9
  13. package/lib/migrateCallLogsSchema.js +128 -7
  14. package/models/callLogModel.js +6 -0
  15. package/package.json +1 -1
  16. package/releaseNotes.json +44 -0
  17. package/test/connector/developerPortal.test.js +166 -0
  18. package/test/connector/mock.test.js +131 -0
  19. package/test/connector/proxy/engine.test.js +85 -0
  20. package/test/connector/proxy/index.test.js +246 -0
  21. package/test/connector/proxy/sample.json +1 -0
  22. package/test/handlers/admin.test.js +344 -0
  23. package/test/handlers/appointment.test.js +260 -0
  24. package/test/handlers/auth.test.js +6 -2
  25. package/test/handlers/calldown.test.js +310 -0
  26. package/test/handlers/disposition.test.js +396 -0
  27. package/test/handlers/log.test.js +327 -3
  28. package/test/handlers/managedOAuth.test.js +262 -0
  29. package/test/handlers/plugin.test.js +305 -0
  30. package/test/handlers/user.test.js +381 -0
  31. package/test/index.test.js +166 -1
  32. package/test/lib/analytics.test.js +146 -0
  33. package/test/lib/authSession.test.js +173 -0
  34. package/test/lib/encode.test.js +59 -0
  35. package/test/lib/errorHandler.test.js +246 -0
  36. package/test/lib/generalErrorMessage.test.js +82 -0
  37. package/test/lib/s3ErrorLogReport.test.js +187 -0
  38. package/test/mcp/mcpHandlerMore.test.js +384 -0
  39. package/test/mcp/tools/appointmentTools.test.js +362 -0
  40. package/test/models/callDownListModel.test.js +125 -0
  41. package/test/models/dynamo/lockSchema.test.js +37 -0
  42. package/test/models/dynamo/noteCacheSchema.test.js +45 -0
  43. package/test/models/llmSessionModel.test.js +91 -0
  44. package/test/models/models.test.js +92 -0
  45. package/test/routes/calldownRoutes.test.js +224 -0
  46. package/test/routes/coreRouterBroadRoutes.test.js +855 -0
  47. package/test/routes/dispositionRoutes.test.js +192 -0
  48. package/test/routes/managedAuthRoutes.test.js +151 -0
  49. package/test/routes/pluginRoutes.test.js +262 -0
@@ -126,6 +126,86 @@ describe('proxy connector - more coverage', () => {
126
126
  expect(token).toBe(Buffer.from('abc:').toString('base64'));
127
127
  });
128
128
 
129
+ test('getOauthInfo maps configured OAuth settings and falls back when config loading fails', async () => {
130
+ const oauthConfig = {
131
+ auth: {
132
+ type: 'oauth',
133
+ clientId: 'client-id',
134
+ clientSecret: 'client-secret',
135
+ tokenUrl: 'https://auth.example.com/token',
136
+ redirectUri: 'https://app.example.com/callback'
137
+ },
138
+ operations: {}
139
+ };
140
+
141
+ await expect(proxy.getOauthInfo({
142
+ proxyConfig: oauthConfig,
143
+ tokenUrl: 'https://override.example.com/token'
144
+ })).resolves.toEqual({
145
+ clientId: 'client-id',
146
+ clientSecret: 'client-secret',
147
+ accessTokenUri: 'https://override.example.com/token',
148
+ redirectUri: 'https://app.example.com/callback'
149
+ });
150
+
151
+ Connector.getProxyConfig.mockRejectedValueOnce(new Error('config unavailable'));
152
+ await expect(proxy.getOauthInfo({ proxyId: 'missing-config' })).resolves.toEqual({});
153
+ await expect(proxy.getAuthType({ proxyId: '' })).resolves.toBe('apiKey');
154
+ });
155
+
156
+ test('getUserInfo returns a warning when the proxy config has no getUserInfo operation', async () => {
157
+ await expect(proxy.getUserInfo({
158
+ proxyConfig: { operations: {} },
159
+ platform: 'minimal'
160
+ })).resolves.toMatchObject({
161
+ successful: false,
162
+ returnMessage: {
163
+ messageType: 'warning',
164
+ message: 'Could not load user information. The platform does not support getUserInfo operation.'
165
+ }
166
+ });
167
+ });
168
+
169
+ test('getUserList maps configured list responses and returns empty when unsupported', async () => {
170
+ const userListConfig = {
171
+ requestDefaults: { baseUrl: 'https://api.example.com' },
172
+ operations: {
173
+ getUserList: {
174
+ method: 'GET',
175
+ url: '/users',
176
+ responseMapping: {
177
+ listPath: 'body.records',
178
+ idPath: 'user.id',
179
+ namePath: 'profile.name',
180
+ emailPath: 'email'
181
+ }
182
+ }
183
+ }
184
+ };
185
+ axios.mockResolvedValueOnce({
186
+ data: {
187
+ records: [
188
+ { user: { id: 'u1' }, profile: { name: 'Ada' }, email: 'ada@example.com' },
189
+ { user: { id: 'u2' }, profile: { name: 'Grace' }, email: 'grace@example.com' }
190
+ ]
191
+ }
192
+ });
193
+
194
+ await expect(proxy.getUserList({
195
+ user: { platformAdditionalInfo: { proxyId: 'p1' } },
196
+ authHeader: 'Bearer token',
197
+ proxyConfig: userListConfig
198
+ })).resolves.toEqual([
199
+ { id: 'u1', name: 'Ada', email: 'ada@example.com' },
200
+ { id: 'u2', name: 'Grace', email: 'grace@example.com' }
201
+ ]);
202
+
203
+ await expect(proxy.getUserList({
204
+ user: { platformAdditionalInfo: { proxyId: 'p1' } },
205
+ proxyConfig: { operations: {} }
206
+ })).resolves.toEqual([]);
207
+ });
208
+
129
209
  test('getUserInfo maps id/name/message/platformAdditionalInfo', async () => {
130
210
  const cfg = JSON.parse(JSON.stringify(sampleConfig));
131
211
  delete cfg.auth; // ensure provided authHeader is used
@@ -165,6 +245,37 @@ describe('proxy connector - more coverage', () => {
165
245
  expect(out.matchedContactInfo[0].id).toBe('c1');
166
246
  });
167
247
 
248
+ test('contact operations return supported empty responses when omitted from manifest', async () => {
249
+ const proxyConfig = { operations: {} };
250
+ const user = { accessToken: 't', platformAdditionalInfo: { proxyId: 'p1' } };
251
+
252
+ await expect(proxy.findContact({
253
+ user,
254
+ authHeader: 'x',
255
+ phoneNumber: '+1',
256
+ proxyConfig
257
+ })).resolves.toEqual({ successful: true, matchedContactInfo: [] });
258
+
259
+ await expect(proxy.findContactWithName({
260
+ user,
261
+ authHeader: 'x',
262
+ name: 'Ada',
263
+ proxyConfig
264
+ })).resolves.toEqual({ successful: true, matchedContactInfo: [] });
265
+
266
+ await expect(proxy.createContact({
267
+ user,
268
+ authHeader: 'x',
269
+ phoneNumber: '+1',
270
+ newContactName: 'Ada',
271
+ newContactType: 'Contact',
272
+ proxyConfig
273
+ })).resolves.toEqual({
274
+ contactInfo: null,
275
+ returnMessage: { message: 'Not supported', messageType: 'warning', ttl: 2000 }
276
+ });
277
+ });
278
+
168
279
  test('createContact maps object response', async () => {
169
280
  Connector.getProxyConfig.mockResolvedValue(sampleConfig);
170
281
  axios.mockResolvedValue({ data: { id: 'c2', name: 'Bob', type: 'Lead' } });
@@ -182,6 +293,70 @@ describe('proxy connector - more coverage', () => {
182
293
  expect(res.returnMessage.messageType).toBe('success');
183
294
  });
184
295
 
296
+ test('call and message log operations return Not supported when omitted from manifest', async () => {
297
+ const proxyConfig = { operations: {} };
298
+ const user = { accessToken: 't', platformAdditionalInfo: { proxyId: 'p1' } };
299
+ const commonCall = {
300
+ user,
301
+ contactInfo: { id: 'c1', name: 'Ada' },
302
+ authHeader: 'x',
303
+ proxyConfig
304
+ };
305
+
306
+ await expect(proxy.createCallLog({
307
+ ...commonCall,
308
+ callLog: { direction: 'Inbound', startTime: Date.now(), duration: 30 },
309
+ note: ''
310
+ })).resolves.toEqual({
311
+ logId: undefined,
312
+ returnMessage: { message: 'Not supported', messageType: 'warning', ttl: 2000 }
313
+ });
314
+
315
+ await expect(proxy.getCallLog({
316
+ user,
317
+ callLogId: 'L1',
318
+ contactId: 'c1',
319
+ authHeader: 'x',
320
+ proxyConfig
321
+ })).resolves.toEqual({
322
+ callLogInfo: null,
323
+ returnMessage: { message: 'Not supported', messageType: 'warning', ttl: 2000 }
324
+ });
325
+
326
+ await expect(proxy.updateCallLog({
327
+ user,
328
+ existingCallLog: { thirdPartyLogId: 'L1' },
329
+ authHeader: 'x',
330
+ startTime: Date.now(),
331
+ duration: 30,
332
+ proxyConfig
333
+ })).resolves.toEqual({
334
+ returnMessage: { message: 'Not supported', messageType: 'warning', ttl: 2000 }
335
+ });
336
+
337
+ await expect(proxy.createMessageLog({
338
+ user,
339
+ contactInfo: { id: 'c1', name: 'Ada' },
340
+ authHeader: 'x',
341
+ message: { creationTime: Date.now() },
342
+ proxyConfig
343
+ })).resolves.toEqual({
344
+ logId: undefined,
345
+ returnMessage: { message: 'Not supported', messageType: 'warning', ttl: 2000 }
346
+ });
347
+
348
+ await expect(proxy.updateMessageLog({
349
+ user,
350
+ contactInfo: { id: 'c1', name: 'Ada' },
351
+ existingMessageLog: { thirdPartyLogId: 'M1' },
352
+ message: { creationTime: Date.now() },
353
+ authHeader: 'x',
354
+ proxyConfig
355
+ })).resolves.toEqual({
356
+ returnMessage: { message: 'Not supported', messageType: 'warning', ttl: 2000 }
357
+ });
358
+ });
359
+
185
360
  test('createMessageLog maps idPath and updateMessageLog returns success', async () => {
186
361
  Connector.getProxyConfig.mockResolvedValue(sampleConfig);
187
362
  axios.mockResolvedValueOnce({ data: { activity: { id: 'M1' } } });
@@ -227,6 +402,40 @@ describe('proxy connector - more coverage', () => {
227
402
  expect(res.returnMessage.message).toMatch(/Not supported/);
228
403
  });
229
404
 
405
+ test('upsertCallDisposition performs configured operation when supported', async () => {
406
+ const dispositionConfig = {
407
+ requestDefaults: { baseUrl: 'https://api.example.com' },
408
+ operations: {
409
+ upsertCallDisposition: {
410
+ method: 'POST',
411
+ url: '/activities/{{thirdPartyLogId}}/disposition',
412
+ body: {
413
+ result: '{{dispositions.0.value}}'
414
+ }
415
+ }
416
+ }
417
+ };
418
+ Connector.getProxyConfig.mockResolvedValue(dispositionConfig);
419
+ axios.mockResolvedValueOnce({ data: { ok: true } });
420
+
421
+ const res = await proxy.upsertCallDisposition({
422
+ user: { accessToken: 't', platformAdditionalInfo: { proxyId: 'p1' } },
423
+ existingCallLog: { thirdPartyLogId: 'L1' },
424
+ authHeader: 'x',
425
+ dispositions: [{ value: 'Connected' }]
426
+ });
427
+
428
+ expect(res).toEqual({
429
+ logId: 'L1',
430
+ returnMessage: { message: 'Disposition updated', messageType: 'success', ttl: 2000 }
431
+ });
432
+ expect(axios.mock.calls[0][0]).toMatchObject({
433
+ method: 'POST',
434
+ url: 'https://api.example.com/activities/L1/disposition',
435
+ data: { result: 'Connected' }
436
+ });
437
+ });
438
+
230
439
  test('getLicenseStatus maps values with custom config', async () => {
231
440
  const licenseConfig = {
232
441
  requestDefaults: { baseUrl: 'https://api.example.com' },
@@ -252,6 +461,23 @@ describe('proxy connector - more coverage', () => {
252
461
  expect(s.licenseStatusDescription).toBe('All good');
253
462
  });
254
463
 
464
+ test('getLicenseStatus handles missing users and missing license operations', async () => {
465
+ UserModel.findByPk.mockResolvedValueOnce(null);
466
+ await expect(proxy.getLicenseStatus({ userId: 'missing', platform: 'x' })).resolves.toEqual({
467
+ isLicenseValid: false,
468
+ licenseStatus: 'Invalid (User not found)',
469
+ licenseStatusDescription: ''
470
+ });
471
+
472
+ UserModel.findByPk.mockResolvedValueOnce({ id: 'u1', accessToken: 't', platformAdditionalInfo: { proxyId: 'p1' } });
473
+ Connector.getProxyConfig.mockResolvedValueOnce({ operations: {} });
474
+ await expect(proxy.getLicenseStatus({ userId: 'u1', platform: 'x' })).resolves.toEqual({
475
+ isLicenseValid: true,
476
+ licenseStatus: 'Basic',
477
+ licenseStatusDescription: ''
478
+ });
479
+ });
480
+
255
481
  test('unAuthorize without custom op clears tokens and saves user', async () => {
256
482
  Connector.getProxyConfig.mockResolvedValue(sampleConfig); // no unAuthorize op
257
483
  const user = { accessToken: 't', refreshToken: 'r', save: jest.fn(), platformAdditionalInfo: { proxyId: 'p1' } };
@@ -275,5 +501,25 @@ describe('proxy connector - more coverage', () => {
275
501
  expect(out.returnMessage.message).toMatch(/Logged out/);
276
502
  expect(user.accessToken).toBe('');
277
503
  });
504
+
505
+ test('unAuthorize returns a database warning when saving cleared tokens fails', async () => {
506
+ Connector.getProxyConfig.mockResolvedValue(sampleConfig);
507
+ const user = {
508
+ accessToken: 't',
509
+ refreshToken: 'r',
510
+ save: jest.fn().mockRejectedValue(new Error('save failed')),
511
+ platformAdditionalInfo: { proxyId: 'p1' }
512
+ };
513
+
514
+ await expect(proxy.unAuthorize({ user })).resolves.toMatchObject({
515
+ successful: false,
516
+ returnMessage: {
517
+ message: 'Database operation failed',
518
+ messageType: 'warning'
519
+ }
520
+ });
521
+ expect(user.accessToken).toBe('');
522
+ expect(user.refreshToken).toBe('');
523
+ });
278
524
  });
279
525
 
@@ -47,6 +47,7 @@
47
47
  "namePath": "name",
48
48
  "typePath": "",
49
49
  "phonePath": "",
50
+ "createdDatePath": "",
50
51
  "additionalInfoPath": ""
51
52
  }
52
53
  }
@@ -29,6 +29,7 @@ const oauth = require('../../lib/oauth');
29
29
  const { RingCentral } = require('../../lib/ringcentral');
30
30
  const { Connector } = require('../../models/dynamo/connectorSchema');
31
31
  const { sequelize } = require('../../models/sequelize');
32
+ const { getHashValue } = require('../../lib/util');
32
33
 
33
34
  describe('Admin Handler', () => {
34
35
  beforeAll(async () => {
@@ -612,5 +613,348 @@ describe('Admin Handler', () => {
612
613
  expect(result[0].rcUser[0].extensionId).toBe('ext-existing');
613
614
  });
614
615
  });
616
+
617
+ describe('reports and mapping branch coverage', () => {
618
+ beforeEach(() => {
619
+ process.env.RINGCENTRAL_SERVER = 'https://platform.example.com';
620
+ process.env.RINGCENTRAL_CLIENT_ID = 'client-id';
621
+ process.env.RINGCENTRAL_CLIENT_SECRET = 'client-secret';
622
+ process.env.APP_SERVER = 'https://app.example.com';
623
+ process.env.HASH_KEY = 'hash-key';
624
+ });
625
+
626
+ afterEach(() => {
627
+ delete process.env.RINGCENTRAL_SERVER;
628
+ delete process.env.RINGCENTRAL_CLIENT_ID;
629
+ delete process.env.RINGCENTRAL_CLIENT_SECRET;
630
+ delete process.env.APP_SERVER;
631
+ delete process.env.HASH_KEY;
632
+ });
633
+
634
+ test('getAdminReport builds aggregation rows and skips unnamed records', async () => {
635
+ const hashedAccountId = getHashValue('account', process.env.HASH_KEY);
636
+ await AdminConfigModel.create({
637
+ id: hashedAccountId,
638
+ adminAccessToken: 'admin-token',
639
+ adminRefreshToken: 'refresh-token',
640
+ adminTokenExpiry: new Date(Date.now() + 60 * 60 * 1000)
641
+ });
642
+ const getCallsAggregationData = jest.fn().mockResolvedValue({
643
+ data: {
644
+ groupedBy: 'Users',
645
+ records: [
646
+ {},
647
+ {
648
+ info: { name: 'Agent 1' },
649
+ counters: {
650
+ callsByDirection: { values: { inbound: 2, outbound: 1 } },
651
+ callsByResponse: { values: { answered: 1 } }
652
+ },
653
+ timers: {
654
+ allCalls: { values: 90 }
655
+ }
656
+ },
657
+ {
658
+ info: { name: 'Agent 2' },
659
+ counters: {
660
+ callsByDirection: { values: { inbound: 0, outbound: 0 } },
661
+ callsByResponse: { values: { answered: 0 } }
662
+ },
663
+ timers: {
664
+ allCalls: { values: 0 }
665
+ }
666
+ }
667
+ ]
668
+ }
669
+ });
670
+ RingCentral.mockImplementation(() => ({
671
+ getCallsAggregationData
672
+ }));
673
+
674
+ const result = await adminHandler.getAdminReport({
675
+ rcAccountId: 'account',
676
+ timezone: 'UTC',
677
+ timeFrom: '2026-07-01T00:00:00Z',
678
+ timeTo: '2026-07-31T23:59:59Z',
679
+ groupBy: 'undefined'
680
+ });
681
+
682
+ expect(result.groupedBy).toBe('Users');
683
+ expect(result.itemKeys).toEqual(['Agent 1', 'Agent 2']);
684
+ expect(result.callLogStats[0]).toMatchObject({
685
+ name: 'Agent 1',
686
+ inboundCallCount: 2,
687
+ outboundCallCount: 1,
688
+ answeredCallCount: 1,
689
+ answeredCallPercentage: '50.00%',
690
+ totalTalkTime: '90.00',
691
+ averageTalkTime: '30.00'
692
+ });
693
+ expect(result.callLogStats[1]).toMatchObject({
694
+ name: 'Agent 2',
695
+ answeredCallPercentage: '0%',
696
+ totalTalkTime: 0,
697
+ averageTalkTime: 0
698
+ });
699
+ expect(getCallsAggregationData).toHaveBeenCalledWith(expect.objectContaining({
700
+ groupBy: 'Company'
701
+ }));
702
+ });
703
+
704
+ test('getUserReport builds call and SMS stats', async () => {
705
+ const hashedAccountId = getHashValue('account', process.env.HASH_KEY);
706
+ await AdminConfigModel.create({
707
+ id: hashedAccountId,
708
+ adminAccessToken: 'admin-token',
709
+ adminRefreshToken: 'refresh-token',
710
+ adminTokenExpiry: new Date(Date.now() + 60 * 60 * 1000)
711
+ });
712
+ RingCentral.mockImplementation(() => ({
713
+ getCallLogData: jest.fn().mockResolvedValue({
714
+ records: [
715
+ { direction: 'Inbound', result: 'Call connected', duration: 120 },
716
+ { direction: 'Inbound', result: 'Missed', duration: 0 },
717
+ { direction: 'Outbound', result: 'Accepted', duration: 60 }
718
+ ]
719
+ }),
720
+ getSMSData: jest.fn().mockResolvedValue({
721
+ records: [
722
+ { direction: 'Outbound' },
723
+ { direction: 'Inbound' },
724
+ { direction: 'Inbound' }
725
+ ]
726
+ })
727
+ }));
728
+
729
+ const result = await adminHandler.getUserReport({
730
+ rcAccountId: 'account',
731
+ rcExtensionId: '101',
732
+ timezone: 'UTC',
733
+ timeFrom: '2026-07-01T00:00:00Z',
734
+ timeTo: '2026-07-31T23:59:59Z'
735
+ });
736
+
737
+ expect(result).toEqual({
738
+ callLogStats: {
739
+ inboundCallCount: 2,
740
+ outboundCallCount: 1,
741
+ answeredCallCount: 1,
742
+ answeredCallPercentage: '50.00%',
743
+ totalTalkTime: 3,
744
+ averageTalkTime: 1
745
+ },
746
+ smsLogStats: {
747
+ smsSentCount: 1,
748
+ smsReceivedCount: 2
749
+ }
750
+ });
751
+ });
752
+
753
+ test('report helpers return empty data on missing env or provider errors', async () => {
754
+ delete process.env.RINGCENTRAL_SERVER;
755
+ await expect(adminHandler.getAdminReport({ rcAccountId: 'account' })).resolves.toEqual({
756
+ callLogStats: {}
757
+ });
758
+ await expect(adminHandler.getUserReport({ rcAccountId: 'account' })).resolves.toEqual({
759
+ callLogStats: {}
760
+ });
761
+
762
+ process.env.RINGCENTRAL_SERVER = 'https://platform.example.com';
763
+ const hashedAccountId = getHashValue('account', process.env.HASH_KEY);
764
+ await AdminConfigModel.create({
765
+ id: hashedAccountId,
766
+ adminAccessToken: 'admin-token',
767
+ adminRefreshToken: 'refresh-token',
768
+ adminTokenExpiry: new Date(Date.now() + 60 * 60 * 1000)
769
+ });
770
+ RingCentral.mockImplementation(() => ({
771
+ getCallsAggregationData: jest.fn().mockRejectedValue(new Error('aggregation failed')),
772
+ getCallLogData: jest.fn().mockRejectedValue(new Error('call log failed'))
773
+ }));
774
+
775
+ await expect(adminHandler.getAdminReport({ rcAccountId: 'account' })).resolves.toEqual({
776
+ callLogStats: {}
777
+ });
778
+ await expect(adminHandler.getUserReport({ rcAccountId: 'account', rcExtensionId: '101' })).resolves.toBeNull();
779
+ });
780
+
781
+ test('getUserMapping updates existing mappings, creates new matches, and handles oauth revocation', async () => {
782
+ await AdminConfigModel.create({
783
+ id: 'hashed-branch-mapping',
784
+ userMappings: [
785
+ { crmUserId: 'crm-user-1', rcExtensionId: 'ext-existing' }
786
+ ]
787
+ });
788
+ const user = {
789
+ platform: 'testCRM',
790
+ accessToken: 'api-key',
791
+ platformAdditionalInfo: {}
792
+ };
793
+ const connector = {
794
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
795
+ getBasicAuth: jest.fn(() => 'encoded-key'),
796
+ getUserList: jest.fn().mockResolvedValue([
797
+ { id: 'crm-user-1', name: 'Existing User', email: 'existing@example.com' },
798
+ { id: 'crm-user-2', name: 'Jane Smith', email: 'jane@example.com' },
799
+ { id: 'crm-user-3', name: 'No Match', email: 'nomatch@example.com' }
800
+ ])
801
+ };
802
+ connectorRegistry.getConnector.mockReturnValue(connector);
803
+
804
+ const result = await adminHandler.getUserMapping({
805
+ user,
806
+ hashedRcAccountId: 'hashed-branch-mapping',
807
+ rcExtensionList: [
808
+ { id: 'ext-existing', name: 'Existing RC', email: 'existing@example.com', extensionNumber: '100' },
809
+ { id: 'ext-jane', firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com', extensionNumber: '101' }
810
+ ]
811
+ });
812
+
813
+ expect(result).toHaveLength(3);
814
+ expect(result[0].rcUser[0].extensionId).toBe('ext-existing');
815
+ expect(result[1].rcUser[0].extensionId).toBe('ext-jane');
816
+ expect(result[2].rcUser).toEqual([]);
817
+ const updatedConfig = await AdminConfigModel.findByPk('hashed-branch-mapping');
818
+ expect(updatedConfig.userMappings).toEqual(expect.arrayContaining([
819
+ expect.objectContaining({ crmUserId: 'crm-user-2', rcExtensionId: ['ext-jane'] })
820
+ ]));
821
+
822
+ connectorRegistry.getConnector.mockReturnValue({
823
+ getUserList: jest.fn(),
824
+ getAuthType: jest.fn().mockResolvedValue('oauth'),
825
+ getOauthInfo: jest.fn().mockResolvedValue({})
826
+ });
827
+ oauth.checkAndRefreshAccessToken.mockResolvedValueOnce(null);
828
+ await expect(adminHandler.getUserMapping({
829
+ user: {
830
+ platform: 'testCRM',
831
+ hostname: 'crm.example.com',
832
+ accessToken: 'expired-token',
833
+ platformAdditionalInfo: {}
834
+ },
835
+ hashedRcAccountId: 'hashed-branch-mapping',
836
+ rcExtensionList: []
837
+ })).resolves.toMatchObject({
838
+ successful: false,
839
+ isRevokeUserSession: true
840
+ });
841
+ });
842
+
843
+ test('getUserMapping initializes mappings when no admin config exists and reports database errors', async () => {
844
+ const user = {
845
+ platform: 'testCRM',
846
+ accessToken: 'api-key',
847
+ platformAdditionalInfo: {}
848
+ };
849
+ connectorRegistry.getConnector.mockReturnValue({
850
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
851
+ getBasicAuth: jest.fn(() => 'encoded-key'),
852
+ getUserList: jest.fn().mockResolvedValue([
853
+ { id: 'crm-user-10', name: 'Alex Agent', email: 'alex@example.com' }
854
+ ])
855
+ });
856
+
857
+ const result = await adminHandler.getUserMapping({
858
+ user,
859
+ hashedRcAccountId: 'hashed-new-mapping',
860
+ rcExtensionList: [
861
+ { id: 'ext-alex', firstName: 'Alex', lastName: 'Agent', email: 'alex@example.com', extensionNumber: '201' }
862
+ ]
863
+ });
864
+
865
+ expect(result[0].rcUser[0].extensionId).toBe('ext-alex');
866
+ const newConfig = await AdminConfigModel.findByPk('hashed-new-mapping');
867
+ expect(newConfig.userMappings).toEqual([
868
+ { crmUserId: 'crm-user-10', rcExtensionId: ['ext-alex'] }
869
+ ]);
870
+
871
+ jest.spyOn(AdminConfigModel, 'findByPk').mockRejectedValueOnce(new Error('read failed'));
872
+ const errorResult = await adminHandler.getUserMapping({
873
+ user,
874
+ hashedRcAccountId: 'hashed-error',
875
+ rcExtensionList: []
876
+ });
877
+ expect(errorResult.successful).toBe(false);
878
+ AdminConfigModel.findByPk.mockRestore();
879
+ });
880
+
881
+ test('reinitializeUserMapping handles missing capability, proxy limits, oauth revoke, apiKey remap, and update failure', async () => {
882
+ const baseUser = {
883
+ platform: 'testCRM',
884
+ accessToken: 'api-key',
885
+ platformAdditionalInfo: {}
886
+ };
887
+
888
+ connectorRegistry.getConnector.mockReturnValueOnce({});
889
+ await expect(adminHandler.reinitializeUserMapping({
890
+ user: baseUser,
891
+ hashedRcAccountId: 'hashed-reinit',
892
+ rcExtensionList: []
893
+ })).resolves.toEqual([]);
894
+
895
+ connectorRegistry.getConnector.mockReturnValueOnce({ getUserList: jest.fn() });
896
+ Connector.getProxyConfig.mockResolvedValueOnce({ operations: {} });
897
+ await expect(adminHandler.reinitializeUserMapping({
898
+ user: {
899
+ ...baseUser,
900
+ platformAdditionalInfo: { proxyId: 'proxy-1' }
901
+ },
902
+ hashedRcAccountId: 'hashed-reinit',
903
+ rcExtensionList: []
904
+ })).resolves.toEqual([]);
905
+
906
+ connectorRegistry.getConnector.mockReturnValueOnce({
907
+ getUserList: jest.fn(),
908
+ getAuthType: jest.fn().mockResolvedValue('oauth'),
909
+ getOauthInfo: jest.fn().mockResolvedValue({})
910
+ });
911
+ oauth.checkAndRefreshAccessToken.mockResolvedValueOnce(null);
912
+ await expect(adminHandler.reinitializeUserMapping({
913
+ user: {
914
+ ...baseUser,
915
+ hostname: 'crm.example.com'
916
+ },
917
+ hashedRcAccountId: 'hashed-reinit',
918
+ rcExtensionList: []
919
+ })).resolves.toMatchObject({
920
+ successful: false,
921
+ isRevokeUserSession: true
922
+ });
923
+
924
+ connectorRegistry.getConnector.mockReturnValueOnce({
925
+ getUserList: jest.fn().mockResolvedValue([
926
+ { id: 'crm-user-20', name: 'Matched User', email: 'matched@example.com' },
927
+ { id: 'crm-user-21', name: 'Unmatched User', email: 'unmatched@example.com' }
928
+ ]),
929
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
930
+ getBasicAuth: jest.fn(() => 'encoded-key')
931
+ });
932
+ const result = await adminHandler.reinitializeUserMapping({
933
+ user: baseUser,
934
+ hashedRcAccountId: 'hashed-reinit',
935
+ rcExtensionList: [
936
+ { id: 'ext-20', firstName: 'Matched', lastName: 'User', email: 'matched@example.com', extensionNumber: '301' }
937
+ ]
938
+ });
939
+
940
+ expect(result).toHaveLength(2);
941
+ expect(result[0].rcUser[0].extensionId).toBe('ext-20');
942
+ expect(result[1].rcUser).toEqual([]);
943
+
944
+ connectorRegistry.getConnector.mockReturnValueOnce({
945
+ getUserList: jest.fn().mockResolvedValue([]),
946
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
947
+ getBasicAuth: jest.fn(() => 'encoded-key')
948
+ });
949
+ jest.spyOn(AdminConfigModel, 'create').mockRejectedValueOnce(new Error('write failed'));
950
+ const failure = await adminHandler.reinitializeUserMapping({
951
+ user: baseUser,
952
+ hashedRcAccountId: 'hashed-reinit-fail',
953
+ rcExtensionList: []
954
+ });
955
+ expect(failure.successful).toBe(false);
956
+ AdminConfigModel.create.mockRestore();
957
+ });
958
+ });
615
959
  });
616
960