@app-connect/core 1.7.35 → 1.7.37

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 (48) 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 +16 -6
  10. package/handlers/plugin.js +13 -6
  11. package/index.js +19 -4
  12. package/lib/callLogLookup.js +82 -9
  13. package/lib/migrateCallLogsSchema.js +315 -7
  14. package/models/callLogModel.js +6 -0
  15. package/package.json +1 -1
  16. package/releaseNotes.json +20 -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/calldown.test.js +310 -0
  25. package/test/handlers/disposition.test.js +396 -0
  26. package/test/handlers/log.test.js +324 -1
  27. package/test/handlers/managedOAuth.test.js +262 -0
  28. package/test/handlers/plugin.test.js +305 -0
  29. package/test/handlers/user.test.js +381 -0
  30. package/test/index.test.js +102 -0
  31. package/test/lib/analytics.test.js +146 -0
  32. package/test/lib/authSession.test.js +173 -0
  33. package/test/lib/encode.test.js +59 -0
  34. package/test/lib/errorHandler.test.js +246 -0
  35. package/test/lib/generalErrorMessage.test.js +82 -0
  36. package/test/lib/s3ErrorLogReport.test.js +187 -0
  37. package/test/mcp/mcpHandlerMore.test.js +384 -0
  38. package/test/mcp/tools/appointmentTools.test.js +362 -0
  39. package/test/models/callDownListModel.test.js +125 -0
  40. package/test/models/dynamo/lockSchema.test.js +37 -0
  41. package/test/models/dynamo/noteCacheSchema.test.js +45 -0
  42. package/test/models/llmSessionModel.test.js +91 -0
  43. package/test/models/models.test.js +92 -0
  44. package/test/routes/calldownRoutes.test.js +224 -0
  45. package/test/routes/coreRouterBroadRoutes.test.js +855 -0
  46. package/test/routes/dispositionRoutes.test.js +192 -0
  47. package/test/routes/managedAuthRoutes.test.js +151 -0
  48. package/test/routes/pluginRoutes.test.js +262 -0
@@ -0,0 +1,131 @@
1
+ jest.mock('shortid', () => ({
2
+ generate: jest.fn(() => 'mock-log-id'),
3
+ }));
4
+
5
+ const mockConnector = require('../../connector/mock');
6
+ const { UserModel } = require('../../models/userModel');
7
+ const { CallLogModel } = require('../../models/callLogModel');
8
+
9
+ describe('mock connector', () => {
10
+ beforeEach(async () => {
11
+ await CallLogModel.destroy({ where: {} });
12
+ await UserModel.destroy({ where: {} });
13
+ });
14
+
15
+ test('creates and deletes the mock user idempotently', async () => {
16
+ const firstUser = await mockConnector.createUser();
17
+ const secondUser = await mockConnector.createUser();
18
+
19
+ expect(firstUser.id).toBe('mockUser');
20
+ expect(secondUser.id).toBe('mockUser');
21
+ await expect(UserModel.count({ where: { id: 'mockUser' } })).resolves.toBe(1);
22
+
23
+ await expect(mockConnector.deleteUser()).resolves.toBe(true);
24
+ await expect(mockConnector.deleteUser()).resolves.toBe(false);
25
+ await expect(UserModel.findByPk('mockUser')).resolves.toBeNull();
26
+ });
27
+
28
+ test('creates a mock call log only once for the same session identity', async () => {
29
+ await mockConnector.createCallLog({
30
+ sessionId: 'session-1',
31
+ extensionNumber: 101,
32
+ hashedExtensionId: 'hash-101',
33
+ });
34
+ await mockConnector.createCallLog({
35
+ sessionId: 'session-1',
36
+ extensionNumber: '101',
37
+ hashedExtensionId: 'hash-101',
38
+ });
39
+
40
+ const records = await CallLogModel.findAll({
41
+ where: {
42
+ sessionId: 'session-1',
43
+ },
44
+ });
45
+ expect(records).toHaveLength(1);
46
+ expect(records[0]).toMatchObject({
47
+ id: 'mock-log-id',
48
+ sessionId: 'session-1',
49
+ extensionNumber: '101',
50
+ hashedExtensionId: 'hash-101',
51
+ userId: 'mockUser',
52
+ });
53
+ });
54
+
55
+ test('returns matched and unmatched call logs in requested session order', async () => {
56
+ await mockConnector.createCallLog({
57
+ sessionId: 'session-1',
58
+ extensionNumber: '101',
59
+ hashedExtensionId: 'hash-101',
60
+ });
61
+ await mockConnector.createCallLog({
62
+ sessionId: 'session-3',
63
+ extensionNumber: '101',
64
+ hashedExtensionId: 'hash-101',
65
+ });
66
+
67
+ await expect(mockConnector.getCallLog({
68
+ sessionIds: 'session-1,session-2,session-3',
69
+ extensionNumber: '101',
70
+ hashedExtensionId: 'hash-101',
71
+ })).resolves.toEqual([
72
+ { sessionId: 'session-1', matched: true, logId: 'mockThirdPartyLogId' },
73
+ { sessionId: 'session-2', matched: false },
74
+ { sessionId: 'session-3', matched: true, logId: 'mockThirdPartyLogId' },
75
+ ]);
76
+ });
77
+
78
+ test('falls back to legacy extension matching when hashed extension id does not match', async () => {
79
+ await CallLogModel.create({
80
+ id: 'legacy-log-id',
81
+ sessionId: 'session-legacy',
82
+ extensionNumber: '101',
83
+ hashedExtensionId: '',
84
+ userId: 'mockUser',
85
+ });
86
+
87
+ await expect(mockConnector.getCallLog({
88
+ sessionIds: 'session-legacy',
89
+ extensionNumber: '101',
90
+ hashedExtensionId: 'new-hash-101',
91
+ })).resolves.toEqual([
92
+ { sessionId: 'session-legacy', matched: true, logId: 'mockThirdPartyLogId' },
93
+ ]);
94
+ });
95
+
96
+ test('cleanUpMockLogs removes only mock user call logs', async () => {
97
+ await CallLogModel.bulkCreate([
98
+ {
99
+ id: 'mock-log',
100
+ sessionId: 'mock-session',
101
+ extensionNumber: '',
102
+ hashedExtensionId: '',
103
+ userId: 'mockUser',
104
+ },
105
+ {
106
+ id: 'real-log',
107
+ sessionId: 'real-session',
108
+ extensionNumber: '',
109
+ hashedExtensionId: '',
110
+ userId: 'realUser',
111
+ },
112
+ ]);
113
+
114
+ await mockConnector.cleanUpMockLogs();
115
+
116
+ await expect(CallLogModel.findOne({
117
+ where: {
118
+ userId: 'mockUser',
119
+ },
120
+ })).resolves.toBeNull();
121
+ const realLog = await CallLogModel.findOne({
122
+ where: {
123
+ userId: 'realUser',
124
+ },
125
+ });
126
+ expect(realLog).toMatchObject({
127
+ id: 'real-log',
128
+ sessionId: 'real-session',
129
+ });
130
+ });
131
+ });
@@ -8,6 +8,7 @@ const {
8
8
  renderDeep,
9
9
  joinUrl,
10
10
  performRequest,
11
+ mapFindContactResponse,
11
12
  } = require('../../../connector/proxy/engine');
12
13
 
13
14
  describe('proxy engine utilities', () => {
@@ -121,6 +122,90 @@ describe('proxy engine utilities', () => {
121
122
  const args = axios.mock.calls[0][0];
122
123
  expect(args.headers.Authorization).toBe(`Basic ${Buffer.from('login-key').toString('base64')}`);
123
124
  });
125
+
126
+ test('mapFindContactResponse maps contact created date', () => {
127
+ const config = {
128
+ operations: {
129
+ findContact: {
130
+ responseMapping: {
131
+ listPath: 'body.contacts',
132
+ item: {
133
+ idPath: 'id',
134
+ namePath: 'name',
135
+ createdDatePath: 'created_at',
136
+ mostRecentActivityDatePath: 'updated_at'
137
+ }
138
+ }
139
+ }
140
+ }
141
+ };
142
+ const response = {
143
+ data: {
144
+ contacts: [
145
+ {
146
+ id: 'c1',
147
+ name: 'Alice',
148
+ created_at: '2024-01-01T00:00:00Z',
149
+ updated_at: '2024-02-01T00:00:00Z'
150
+ }
151
+ ]
152
+ }
153
+ };
154
+
155
+ expect(mapFindContactResponse({ config, response })).toEqual([
156
+ expect.objectContaining({
157
+ id: 'c1',
158
+ name: 'Alice',
159
+ createdDate: '2024-01-01T00:00:00Z',
160
+ mostRecentActivityDate: '2024-02-01T00:00:00Z'
161
+ })
162
+ ]);
163
+ });
164
+
165
+ test('mapFindContactResponse can map findContactWithName responses', () => {
166
+ const config = {
167
+ operations: {
168
+ findContact: {
169
+ responseMapping: {
170
+ listPath: 'body.phoneMatches',
171
+ item: {
172
+ idPath: 'id',
173
+ namePath: 'name'
174
+ }
175
+ }
176
+ },
177
+ findContactWithName: {
178
+ responseMapping: {
179
+ listPath: 'body.nameMatches',
180
+ item: {
181
+ idPath: 'personId',
182
+ namePath: 'displayName',
183
+ createdDatePath: 'created'
184
+ }
185
+ }
186
+ }
187
+ }
188
+ };
189
+ const response = {
190
+ data: {
191
+ nameMatches: [
192
+ {
193
+ personId: 'p1',
194
+ displayName: 'Jane Smith',
195
+ created: '2023-05-01T00:00:00Z'
196
+ }
197
+ ]
198
+ }
199
+ };
200
+
201
+ expect(mapFindContactResponse({ config, response, opName: 'findContactWithName' })).toEqual([
202
+ expect.objectContaining({
203
+ id: 'p1',
204
+ name: 'Jane Smith',
205
+ createdDate: '2023-05-01T00:00:00Z'
206
+ })
207
+ ]);
208
+ });
124
209
  });
125
210
 
126
211
 
@@ -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
  }