@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
@@ -38,6 +38,7 @@ const { composeCallLog } = require('../../lib/callLogComposer');
38
38
  const { NoteCache } = require('../../models/dynamo/noteCacheSchema');
39
39
  const axios = require('axios');
40
40
  const { sequelize } = require('../../models/sequelize');
41
+ const { getHashValue } = require('../../lib/util');
41
42
 
42
43
  describe('Log Handler', () => {
43
44
  beforeAll(async () => {
@@ -170,6 +171,82 @@ describe('Log Handler', () => {
170
171
  expect(savedLog.thirdPartyLogId).toBe('new-log-102');
171
172
  });
172
173
 
174
+ test('should block duplicate client log when SSCL already logged same hashed extension', async () => {
175
+ // Arrange
176
+ const hashedExtensionId = getHashValue('rc-ext-1', 'test-hash-key');
177
+ await CallLogModel.create({
178
+ id: 'tel-session-123',
179
+ sessionId: 'session-123',
180
+ extensionNumber: '',
181
+ hashedExtensionId,
182
+ platform: 'testCRM',
183
+ thirdPartyLogId: 'sscl-log',
184
+ userId: 'test-user-id'
185
+ });
186
+
187
+ const incomingData = {
188
+ ...mockIncomingData,
189
+ extensionNumber: '101',
190
+ hashedExtensionId
191
+ };
192
+
193
+ // Act
194
+ const result = await logHandler.createCallLog({
195
+ platform: 'testCRM',
196
+ userId: 'test-user-id',
197
+ incomingData,
198
+ hashedAccountId: 'hashed-123',
199
+ isFromSSCL: false
200
+ });
201
+
202
+ // Assert
203
+ expect(result.successful).toBe(false);
204
+ expect(result.returnMessage.message).toContain('Existing log for session');
205
+ });
206
+
207
+ test('should derive hashed extension id from SSCL raw rcExtensionId when saving', async () => {
208
+ // Arrange
209
+ process.env.HASH_KEY = 'test-hash-key';
210
+ await UserModel.create(mockUser);
211
+
212
+ const mockConnector = {
213
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
214
+ getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
215
+ getLogFormatType: jest.fn().mockReturnValue('text/plain'),
216
+ createCallLog: jest.fn().mockResolvedValue({
217
+ logId: 'new-sscl-log',
218
+ returnMessage: { message: 'Call logged', messageType: 'success', ttl: 2000 }
219
+ })
220
+ };
221
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
222
+ composeCallLog.mockReturnValue('Composed log details');
223
+
224
+ const incomingData = {
225
+ ...mockIncomingData,
226
+ rcExtensionId: 'rc-ext-1'
227
+ };
228
+
229
+ // Act
230
+ const result = await logHandler.createCallLog({
231
+ platform: 'testCRM',
232
+ userId: 'test-user-id',
233
+ incomingData,
234
+ hashedAccountId: 'hashed-123',
235
+ isFromSSCL: true
236
+ });
237
+
238
+ // Assert
239
+ expect(result.successful).toBe(true);
240
+ const savedLog = await CallLogModel.findOne({
241
+ where: {
242
+ sessionId: 'session-123',
243
+ hashedExtensionId: getHashValue('rc-ext-1', 'test-hash-key')
244
+ }
245
+ });
246
+ expect(savedLog).not.toBeNull();
247
+ expect(savedLog.thirdPartyLogId).toBe('new-sscl-log');
248
+ });
249
+
173
250
  test('should return warning when user not found', async () => {
174
251
  // Act
175
252
  const result = await logHandler.createCallLog({
@@ -324,7 +401,7 @@ describe('Log Handler', () => {
324
401
  expect(result.successful).toBe(true);
325
402
  expect(axios.post).toHaveBeenCalledWith(
326
403
  'https://plugins.example.com/plugin/testPlugin',
327
- { data: mockIncomingData, config: null },
404
+ { data: mockIncomingData, config: null, hashedExtensionId: null },
328
405
  {
329
406
  headers: {
330
407
  Authorization: 'Bearer plugin-jwt-token'
@@ -402,7 +479,8 @@ describe('Log Handler', () => {
402
479
  data: mockIncomingData,
403
480
  config: null,
404
481
  asyncTaskId: cache.id,
405
- callbackUrl: `https://app.example.com/plugin/async-callback/${cache.id}`
482
+ callbackUrl: `https://app.example.com/plugin/async-callback/${cache.id}`,
483
+ hashedExtensionId: null
406
484
  });
407
485
  expect(pluginCall[2]).toEqual({
408
486
  headers: {
@@ -418,6 +496,53 @@ describe('Log Handler', () => {
418
496
  }
419
497
  });
420
498
 
499
+ test('should mark async plugin task failed when token sync URL is missing', async () => {
500
+ await UserModel.create(mockUser);
501
+ await AccountDataModel.create({
502
+ rcAccountId: mockUser.rcAccountId,
503
+ platformName: 'asyncPlugin',
504
+ dataKey: 'pluginData',
505
+ data: {
506
+ name: 'plugin.async',
507
+ supportedLogTypes: ['call'],
508
+ isAsync: true,
509
+ endpointUrl: 'https://plugins.example.com/plugin/asyncPlugin',
510
+ jwtToken: 'plugin-jwt-token'
511
+ }
512
+ });
513
+
514
+ const mockConnector = {
515
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
516
+ getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
517
+ getLogFormatType: jest.fn().mockReturnValue('text/plain'),
518
+ createCallLog: jest.fn().mockResolvedValue({
519
+ logId: 'new-log-async-failed',
520
+ returnMessage: { message: 'Call logged', messageType: 'success', ttl: 2000 }
521
+ })
522
+ };
523
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
524
+ composeCallLog.mockReturnValue('Composed log details');
525
+
526
+ const result = await logHandler.createCallLog({
527
+ platform: 'testCRM',
528
+ userId: 'test-user-id',
529
+ incomingData: mockIncomingData,
530
+ hashedAccountId: 'hashed-123',
531
+ isFromSSCL: false
532
+ });
533
+
534
+ expect(result.successful).toBe(true);
535
+ const cache = await CacheModel.findOne({
536
+ where: {
537
+ cacheKey: 'asyncPluginTask-asyncPlugin'
538
+ }
539
+ });
540
+ expect(cache).not.toBeNull();
541
+ expect(cache.status).toBe('failed');
542
+ expect(cache.data.message).toBe('Plugin token sync URL is not set');
543
+ expect(axios.post).not.toHaveBeenCalled();
544
+ });
545
+
421
546
  test('should successfully create call log with oauth auth', async () => {
422
547
  // Arrange
423
548
  const oauthUser = { ...mockUser };
@@ -616,6 +741,43 @@ describe('Log Handler', () => {
616
741
  }]);
617
742
  });
618
743
 
744
+ test('should match SSCL log by hashedExtensionId when extensionNumber differs', async () => {
745
+ // Arrange
746
+ await UserModel.create({
747
+ id: 'test-user-id',
748
+ platform: 'testCRM',
749
+ accessToken: 'test-token'
750
+ });
751
+
752
+ await CallLogModel.create({
753
+ id: 'call-1',
754
+ sessionId: 'session-1',
755
+ extensionNumber: '',
756
+ hashedExtensionId: 'hashed-ext-1',
757
+ platform: 'testCRM',
758
+ thirdPartyLogId: 'sscl-log',
759
+ userId: 'test-user-id'
760
+ });
761
+
762
+ // Act
763
+ const result = await logHandler.getCallLog({
764
+ userId: 'test-user-id',
765
+ sessionIds: 'session-1',
766
+ extensionNumber: '101',
767
+ hashedExtensionId: 'hashed-ext-1',
768
+ platform: 'testCRM',
769
+ requireDetails: false
770
+ });
771
+
772
+ // Assert
773
+ expect(result.successful).toBe(true);
774
+ expect(result.logs).toEqual([{
775
+ sessionId: 'session-1',
776
+ matched: true,
777
+ logId: 'sscl-log'
778
+ }]);
779
+ });
780
+
619
781
  test('should keep session-only lookup backward compatible when extensionNumber is empty', async () => {
620
782
  // Arrange
621
783
  await UserModel.create({
@@ -1290,6 +1452,21 @@ describe('Log Handler', () => {
1290
1452
  });
1291
1453
 
1292
1454
  describe('handleAsyncPluginCallback', () => {
1455
+ test('should return not found when callback task cache is missing', async () => {
1456
+ const result = await logHandler.handleAsyncPluginCallback({
1457
+ taskId: 'missing-task',
1458
+ body: {
1459
+ successful: true,
1460
+ note: 'Callback note'
1461
+ }
1462
+ });
1463
+
1464
+ expect(result).toEqual({
1465
+ statusCode: 404,
1466
+ body: { successful: false, message: 'Async task not found' }
1467
+ });
1468
+ });
1469
+
1293
1470
  test('should append callback note to call log and remove task cache on success', async () => {
1294
1471
  await UserModel.create({
1295
1472
  id: 'test-user-id',
@@ -1411,6 +1588,154 @@ describe('Log Handler', () => {
1411
1588
  expect(cache.data.message).toBe('Plugin failed');
1412
1589
  });
1413
1590
 
1591
+ test('should destroy expired task cache and return not found', async () => {
1592
+ await CacheModel.create({
1593
+ id: 'task-expired',
1594
+ status: 'pending',
1595
+ userId: 'test-user-id',
1596
+ cacheKey: 'asyncPluginTask-testPlugin',
1597
+ expiry: new Date(Date.now() - 1000),
1598
+ data: {
1599
+ pluginId: 'testPlugin',
1600
+ platform: 'testCRM',
1601
+ userId: 'test-user-id',
1602
+ sessionId: 'session-1'
1603
+ }
1604
+ });
1605
+
1606
+ const result = await logHandler.handleAsyncPluginCallback({
1607
+ taskId: 'task-expired',
1608
+ body: {
1609
+ successful: true,
1610
+ note: 'Late callback note'
1611
+ }
1612
+ });
1613
+
1614
+ expect(result).toEqual({
1615
+ statusCode: 404,
1616
+ body: { successful: false, message: 'Async task not found' }
1617
+ });
1618
+ expect(await CacheModel.findByPk('task-expired')).toBeNull();
1619
+ expect(connectorRegistry.getConnector).not.toHaveBeenCalled();
1620
+ });
1621
+
1622
+ test('should mark task failed when callback cannot find the original call log', async () => {
1623
+ await CacheModel.create({
1624
+ id: 'task-missing-log',
1625
+ status: 'pending',
1626
+ userId: 'test-user-id',
1627
+ cacheKey: 'asyncPluginTask-testPlugin',
1628
+ expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
1629
+ data: {
1630
+ pluginId: 'testPlugin',
1631
+ platform: 'testCRM',
1632
+ userId: 'test-user-id',
1633
+ sessionId: 'missing-session',
1634
+ extensionNumber: '',
1635
+ incomingData: {
1636
+ logInfo: {
1637
+ sessionId: 'missing-session'
1638
+ }
1639
+ }
1640
+ }
1641
+ });
1642
+
1643
+ const result = await logHandler.handleAsyncPluginCallback({
1644
+ taskId: 'task-missing-log',
1645
+ body: {
1646
+ successful: true,
1647
+ note: 'Callback note'
1648
+ }
1649
+ });
1650
+
1651
+ expect(result).toEqual({
1652
+ statusCode: 500,
1653
+ body: { successful: false, message: 'Call log not found for async plugin task' }
1654
+ });
1655
+ const cache = await CacheModel.findByPk('task-missing-log');
1656
+ expect(cache.status).toBe('failed');
1657
+ expect(cache.data.message).toBe('Call log not found for async plugin task');
1658
+ expect(connectorRegistry.getConnector).not.toHaveBeenCalled();
1659
+ });
1660
+
1661
+ test('should mark task failed when callback note cannot be written to CRM', async () => {
1662
+ await UserModel.create({
1663
+ id: 'test-user-id',
1664
+ platform: 'testCRM',
1665
+ accessToken: 'test-token',
1666
+ platformAdditionalInfo: {}
1667
+ });
1668
+ await CallLogModel.create({
1669
+ id: 'call-1',
1670
+ sessionId: 'session-1',
1671
+ extensionNumber: '',
1672
+ platform: 'testCRM',
1673
+ thirdPartyLogId: 'log-1',
1674
+ userId: 'test-user-id',
1675
+ contactId: 'contact-1'
1676
+ });
1677
+ await CacheModel.create({
1678
+ id: 'task-update-failed',
1679
+ status: 'pending',
1680
+ userId: 'test-user-id',
1681
+ cacheKey: 'asyncPluginTask-testPlugin',
1682
+ expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
1683
+ data: {
1684
+ pluginId: 'testPlugin',
1685
+ platform: 'testCRM',
1686
+ userId: 'test-user-id',
1687
+ sessionId: 'session-1',
1688
+ extensionNumber: '',
1689
+ incomingData: {
1690
+ logInfo: {
1691
+ sessionId: 'session-1',
1692
+ startTime: '2026-06-12T10:00:00.000Z',
1693
+ duration: 120,
1694
+ result: 'Completed',
1695
+ direction: 'Outbound',
1696
+ from: { phoneNumber: '+1234567890' },
1697
+ to: { phoneNumber: '+1987654321' }
1698
+ },
1699
+ note: 'Original note'
1700
+ },
1701
+ hashedAccountId: 'hashed-123',
1702
+ isFromSSCL: false
1703
+ }
1704
+ });
1705
+ const mockConnector = {
1706
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
1707
+ getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
1708
+ getLogFormatType: jest.fn().mockReturnValue('text/plain'),
1709
+ getCallLog: jest.fn().mockResolvedValue({
1710
+ callLogInfo: {
1711
+ fullBody: 'Existing body',
1712
+ note: 'Existing note',
1713
+ fullLogResponse: { id: 'log-1' }
1714
+ }
1715
+ }),
1716
+ updateCallLog: jest.fn().mockRejectedValue(new Error('CRM update failed'))
1717
+ };
1718
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
1719
+ composeCallLog.mockReturnValue('Updated composed log');
1720
+
1721
+ const result = await logHandler.handleAsyncPluginCallback({
1722
+ taskId: 'task-update-failed',
1723
+ body: {
1724
+ successful: true,
1725
+ note: 'Callback note'
1726
+ }
1727
+ });
1728
+
1729
+ expect(result).toEqual({
1730
+ statusCode: 500,
1731
+ body: { successful: false, message: 'CRM update failed' }
1732
+ });
1733
+ const cache = await CacheModel.findByPk('task-update-failed');
1734
+ expect(cache).not.toBeNull();
1735
+ expect(cache.status).toBe('failed');
1736
+ expect(cache.data.message).toBe('CRM update failed');
1737
+ expect(mockConnector.updateCallLog).toHaveBeenCalled();
1738
+ });
1414
1739
  test('should reject callback without successful boolean', async () => {
1415
1740
  const result = await logHandler.handleAsyncPluginCallback({
1416
1741
  taskId: 'task-1',
@@ -1520,4 +1845,3 @@ describe('Log Handler', () => {
1520
1845
  });
1521
1846
  });
1522
1847
  });
1523
-
@@ -0,0 +1,262 @@
1
+ jest.mock('../../models/sequelize', () => {
2
+ const { Sequelize } = require('sequelize');
3
+ return {
4
+ sequelize: new Sequelize({
5
+ dialect: 'sqlite',
6
+ storage: ':memory:',
7
+ logging: false
8
+ })
9
+ };
10
+ });
11
+
12
+ const managedOAuth = require('../../handlers/managedOAuth');
13
+ const { AccountDataModel } = require('../../models/accountDataModel');
14
+ const { CacheModel } = require('../../models/cacheModel');
15
+ const { sequelize } = require('../../models/sequelize');
16
+
17
+ describe('Managed OAuth Handler', () => {
18
+ beforeAll(async () => {
19
+ process.env.APP_SERVER_SECRET_KEY = 'test-app-server-secret-key-123456';
20
+ await AccountDataModel.sync({ force: true });
21
+ await CacheModel.sync({ force: true });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await AccountDataModel.destroy({ where: {} });
26
+ await CacheModel.destroy({ where: {} });
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await sequelize.close();
31
+ });
32
+
33
+ test('returns empty state when account and platform are missing', async () => {
34
+ await expect(managedOAuth.getManagedOAuthState({
35
+ rcAccountId: '',
36
+ platform: '',
37
+ isAdmin: false
38
+ })).resolves.toEqual({
39
+ isAdmin: false,
40
+ hasAccountOAuth: false,
41
+ hasPendingOAuth: false
42
+ });
43
+
44
+ await expect(managedOAuth.clearPendingManagedOAuth({})).resolves.toBe(0);
45
+ await expect(managedOAuth.clearAccountManagedOAuth({ rcAccountId: 'acc-1' })).resolves.toBe(0);
46
+ });
47
+
48
+ test('upserts pending OAuth values, filters empty fields, and exposes pending values to admin', async () => {
49
+ const firstRecord = await managedOAuth.upsertPendingManagedOAuth({
50
+ rcAccountId: 'acc-1',
51
+ values: {
52
+ clientId: 'client-id',
53
+ clientSecret: 'client-secret',
54
+ accessTokenUri: '',
55
+ authorizationUri: 'https://auth.example.com',
56
+ ignored: 'ignored'
57
+ }
58
+ });
59
+
60
+ expect(firstRecord.id).toBe('acc-1-managed-oauth-account');
61
+
62
+ const secondRecord = await managedOAuth.upsertPendingManagedOAuth({
63
+ rcAccountId: 'acc-1',
64
+ values: {
65
+ clientId: 'client-id-2',
66
+ redirectUri: 'https://app.example.com/callback',
67
+ scopes: ['read', 'write'],
68
+ hostname: 'crm.example.com'
69
+ }
70
+ });
71
+
72
+ expect(secondRecord.id).toBe(firstRecord.id);
73
+
74
+ const pendingValues = await managedOAuth.getPendingManagedOAuthValues({ rcAccountId: 'acc-1' });
75
+ expect(pendingValues).toEqual({
76
+ clientId: 'client-id-2',
77
+ redirectUri: 'https://app.example.com/callback',
78
+ scopes: ['read', 'write'],
79
+ hostname: 'crm.example.com'
80
+ });
81
+
82
+ const state = await managedOAuth.getManagedOAuthState({
83
+ rcAccountId: 'acc-1',
84
+ platform: 'testCRM',
85
+ isAdmin: true
86
+ });
87
+
88
+ expect(state).toEqual({
89
+ isAdmin: true,
90
+ hasAccountOAuth: false,
91
+ hasPendingOAuth: true,
92
+ pendingValues
93
+ });
94
+ });
95
+
96
+ test('expired pending OAuth cache is destroyed and ignored', async () => {
97
+ const record = await managedOAuth.upsertPendingManagedOAuth({
98
+ rcAccountId: 'expired-acc',
99
+ values: {
100
+ clientId: 'expired-client'
101
+ }
102
+ });
103
+ await record.update({
104
+ expiry: new Date(Date.now() - 1000)
105
+ });
106
+
107
+ const values = await managedOAuth.getPendingManagedOAuthValues({ rcAccountId: 'expired-acc' });
108
+
109
+ expect(values).toEqual({});
110
+ expect(await CacheModel.findByPk('expired-acc-managed-oauth-account')).toBeNull();
111
+ });
112
+
113
+ test('migrates pending OAuth to account record and hides account client secret in state', async () => {
114
+ await managedOAuth.upsertPendingManagedOAuth({
115
+ rcAccountId: 'acc-2',
116
+ values: {
117
+ clientId: 'client-id',
118
+ clientSecret: 'client-secret',
119
+ accessTokenUri: 'https://token.example.com'
120
+ }
121
+ });
122
+
123
+ await expect(managedOAuth.migratePendingManagedOAuth({
124
+ rcAccountId: 'acc-2',
125
+ platform: 'testCRM'
126
+ })).resolves.toBe(true);
127
+
128
+ expect(await managedOAuth.getPendingManagedOAuthValues({ rcAccountId: 'acc-2' })).toEqual({});
129
+
130
+ const accountValues = await managedOAuth.getAccountManagedOAuthValues({
131
+ rcAccountId: 'acc-2',
132
+ platform: 'testCRM'
133
+ });
134
+ expect(accountValues).toEqual({
135
+ clientId: 'client-id',
136
+ clientSecret: 'client-secret',
137
+ accessTokenUri: 'https://token.example.com'
138
+ });
139
+
140
+ const state = await managedOAuth.getManagedOAuthState({
141
+ rcAccountId: 'acc-2',
142
+ platform: 'testCRM',
143
+ isAdmin: true
144
+ });
145
+
146
+ expect(state).toEqual({
147
+ isAdmin: true,
148
+ hasAccountOAuth: true,
149
+ hasPendingOAuth: false,
150
+ oauthValues: {
151
+ clientId: 'client-id',
152
+ accessTokenUri: 'https://token.example.com'
153
+ }
154
+ });
155
+ });
156
+
157
+ test('migrates pending OAuth into an existing account record and resolves account source first', async () => {
158
+ await managedOAuth.upsertPendingManagedOAuth({
159
+ rcAccountId: 'acc-3',
160
+ values: {
161
+ clientId: 'pending-client'
162
+ }
163
+ });
164
+ await AccountDataModel.create({
165
+ rcAccountId: 'acc-3',
166
+ platformName: 'testCRM',
167
+ dataKey: managedOAuth.MANAGED_OAUTH_ACCOUNT_DATA_KEY,
168
+ data: {
169
+ fields: {
170
+ clientId: {
171
+ encrypted: false,
172
+ value: 'old-client'
173
+ }
174
+ }
175
+ }
176
+ });
177
+
178
+ await expect(managedOAuth.migratePendingManagedOAuth({
179
+ rcAccountId: 'acc-3',
180
+ platform: 'testCRM'
181
+ })).resolves.toBe(true);
182
+
183
+ await expect(managedOAuth.resolveManagedOAuthInfo({
184
+ rcAccountId: 'acc-3',
185
+ platform: 'testCRM'
186
+ })).resolves.toEqual({
187
+ source: 'account',
188
+ oauthInfo: {
189
+ clientId: 'pending-client'
190
+ }
191
+ });
192
+ });
193
+
194
+ test('resolveManagedOAuthInfo falls back to pending values or null source', async () => {
195
+ await managedOAuth.upsertPendingManagedOAuth({
196
+ rcAccountId: 'acc-4',
197
+ values: {
198
+ clientId: 'pending-client'
199
+ }
200
+ });
201
+
202
+ await expect(managedOAuth.resolveManagedOAuthInfo({
203
+ rcAccountId: 'acc-4',
204
+ platform: 'testCRM'
205
+ })).resolves.toEqual({
206
+ source: 'pending',
207
+ oauthInfo: {
208
+ clientId: 'pending-client'
209
+ }
210
+ });
211
+
212
+ await expect(managedOAuth.resolveManagedOAuthInfo({
213
+ rcAccountId: 'missing-acc',
214
+ platform: 'testCRM'
215
+ })).resolves.toEqual({
216
+ source: null,
217
+ oauthInfo: null
218
+ });
219
+ });
220
+
221
+ test('clear and reset delete account and pending OAuth data', async () => {
222
+ await managedOAuth.upsertPendingManagedOAuth({
223
+ rcAccountId: 'acc-5',
224
+ values: {
225
+ clientId: 'pending-client'
226
+ }
227
+ });
228
+ await managedOAuth.migratePendingManagedOAuth({
229
+ rcAccountId: 'acc-5',
230
+ platform: 'testCRM'
231
+ });
232
+ await managedOAuth.upsertPendingManagedOAuth({
233
+ rcAccountId: 'acc-5',
234
+ values: {
235
+ clientId: 'pending-client-2'
236
+ }
237
+ });
238
+
239
+ const resetResult = await managedOAuth.resetManagedOAuth({
240
+ rcAccountId: 'acc-5',
241
+ platform: 'testCRM'
242
+ });
243
+
244
+ expect(resetResult).toEqual({
245
+ deletedAccountCount: 1,
246
+ deletedPendingCount: 1
247
+ });
248
+ });
249
+
250
+ test('throws when upserting pending OAuth without account id and no-ops missing migrations', async () => {
251
+ await expect(managedOAuth.upsertPendingManagedOAuth({
252
+ values: {
253
+ clientId: 'client-id'
254
+ }
255
+ })).rejects.toThrow('rcAccountId is required');
256
+
257
+ await expect(managedOAuth.migratePendingManagedOAuth({
258
+ rcAccountId: 'no-pending',
259
+ platform: 'testCRM'
260
+ })).resolves.toBe(false);
261
+ });
262
+ });