@app-connect/core 1.7.35 → 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 (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 +10 -4
  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 +16 -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
@@ -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({
@@ -419,6 +496,53 @@ describe('Log Handler', () => {
419
496
  }
420
497
  });
421
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
+
422
546
  test('should successfully create call log with oauth auth', async () => {
423
547
  // Arrange
424
548
  const oauthUser = { ...mockUser };
@@ -617,6 +741,43 @@ describe('Log Handler', () => {
617
741
  }]);
618
742
  });
619
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
+
620
781
  test('should keep session-only lookup backward compatible when extensionNumber is empty', async () => {
621
782
  // Arrange
622
783
  await UserModel.create({
@@ -1291,6 +1452,21 @@ describe('Log Handler', () => {
1291
1452
  });
1292
1453
 
1293
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
+
1294
1470
  test('should append callback note to call log and remove task cache on success', async () => {
1295
1471
  await UserModel.create({
1296
1472
  id: 'test-user-id',
@@ -1412,6 +1588,154 @@ describe('Log Handler', () => {
1412
1588
  expect(cache.data.message).toBe('Plugin failed');
1413
1589
  });
1414
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
+ });
1415
1739
  test('should reject callback without successful boolean', async () => {
1416
1740
  const result = await logHandler.handleAsyncPluginCallback({
1417
1741
  taskId: 'task-1',
@@ -1521,4 +1845,3 @@ describe('Log Handler', () => {
1521
1845
  });
1522
1846
  });
1523
1847
  });
1524
-
@@ -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
+ });