@app-connect/core 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.test +5 -0
- package/README.md +165 -6
- package/adapter/registry.js +137 -5
- package/jest.config.js +57 -0
- package/package.json +7 -1
- package/test/adapter/registry.test.js +271 -0
- package/test/handlers/auth.test.js +231 -0
- package/test/lib/jwt.test.js +161 -0
- package/test/setup.js +176 -0
package/.env.test
ADDED
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Core package for RingCentral App Connect project providing modular APIs for CRM
|
|
|
10
10
|
- **Contact Management**: Find, create, and manage contacts across CRM platforms
|
|
11
11
|
- **Call Logging**: Comprehensive call and message logging capabilities
|
|
12
12
|
- **Analytics**: Built-in analytics tracking for all operations
|
|
13
|
-
- **Database Integration**:
|
|
13
|
+
- **Database Integration**: Sequelize.js ORM with automatic table management
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
@@ -39,6 +39,69 @@ app.get('/my-custom-route', (req, res) => {
|
|
|
39
39
|
exports.getServer = () => app;
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
### Adapter Interface Registration
|
|
43
|
+
|
|
44
|
+
The adapter registry supports dynamic interface registration, allowing you to extend adapter functionality without modifying the original adapter:
|
|
45
|
+
|
|
46
|
+
```javascript
|
|
47
|
+
const { adapterRegistry } = require('@app-connect/core');
|
|
48
|
+
|
|
49
|
+
// Register interface functions for a platform
|
|
50
|
+
async function customCreateCallLog({ user, contactInfo, authHeader, callLog, note }) {
|
|
51
|
+
// Custom implementation
|
|
52
|
+
return {
|
|
53
|
+
logId: 'custom-log-id',
|
|
54
|
+
returnMessage: {
|
|
55
|
+
message: 'Call logged with custom implementation',
|
|
56
|
+
messageType: 'success',
|
|
57
|
+
ttl: 2000
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function customFindContact({ user, authHeader, phoneNumber }) {
|
|
63
|
+
// Custom implementation
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
id: 'custom-contact-id',
|
|
67
|
+
name: 'Custom Contact',
|
|
68
|
+
type: 'Contact',
|
|
69
|
+
phone: phoneNumber,
|
|
70
|
+
additionalInfo: null
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Register interface functions
|
|
76
|
+
adapterRegistry.registerAdapterInterface('myCRM', 'createCallLog', customCreateCallLog);
|
|
77
|
+
adapterRegistry.registerAdapterInterface('myCRM', 'findContact', customFindContact);
|
|
78
|
+
|
|
79
|
+
// Register the base adapter
|
|
80
|
+
adapterRegistry.registerAdapter('myCRM', myCRMAdapter);
|
|
81
|
+
|
|
82
|
+
// Get composed adapter with interfaces
|
|
83
|
+
const composedAdapter = adapterRegistry.getAdapter('myCRM');
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Interface-Only Adapters (No Base Adapter):**
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
// Register only interface functions, no base adapter
|
|
90
|
+
adapterRegistry.registerAdapterInterface('interfaceOnlyCRM', 'createCallLog', customCreateCallLog);
|
|
91
|
+
adapterRegistry.registerAdapterInterface('interfaceOnlyCRM', 'findContact', customFindContact);
|
|
92
|
+
|
|
93
|
+
// Get interface-only adapter
|
|
94
|
+
const interfaceOnlyAdapter = adapterRegistry.getAdapter('interfaceOnlyCRM');
|
|
95
|
+
console.log('Interface-only methods:', Object.keys(interfaceOnlyAdapter));
|
|
96
|
+
// Output: ['createCallLog', 'findContact']
|
|
97
|
+
|
|
98
|
+
// Later, you can add a base adapter
|
|
99
|
+
adapterRegistry.registerAdapter('interfaceOnlyCRM', myCRMAdapter);
|
|
100
|
+
const fullAdapter = adapterRegistry.getAdapter('interfaceOnlyCRM');
|
|
101
|
+
console.log('Full adapter methods:', Object.keys(fullAdapter));
|
|
102
|
+
// Output: ['getAuthType', 'getUserInfo', 'updateCallLog', 'unAuthorize', 'createContact', 'createCallLog', 'findContact']
|
|
103
|
+
```
|
|
104
|
+
|
|
42
105
|
### Advanced Usage with Custom Middleware
|
|
43
106
|
|
|
44
107
|
```javascript
|
|
@@ -125,13 +188,84 @@ Registers a CRM adapter.
|
|
|
125
188
|
- `adapter` (Object): Adapter implementation
|
|
126
189
|
- `manifest` (Object, optional): Adapter manifest
|
|
127
190
|
|
|
191
|
+
#### `adapterRegistry.registerAdapterInterface(platformName, interfaceName, interfaceFunction)`
|
|
192
|
+
Registers an interface function for a specific platform that will be composed with the adapter at retrieval time.
|
|
193
|
+
|
|
194
|
+
**Parameters:**
|
|
195
|
+
- `platformName` (String): Platform identifier (e.g., 'pipedrive', 'salesforce')
|
|
196
|
+
- `interfaceName` (String): Interface function name (e.g., 'createCallLog', 'findContact')
|
|
197
|
+
- `interfaceFunction` (Function): The interface function to register
|
|
198
|
+
|
|
199
|
+
**Example:**
|
|
200
|
+
```javascript
|
|
201
|
+
async function customCreateCallLog({ user, contactInfo, authHeader, callLog, note }) {
|
|
202
|
+
// Custom implementation
|
|
203
|
+
return {
|
|
204
|
+
logId: 'custom-log-id',
|
|
205
|
+
returnMessage: {
|
|
206
|
+
message: 'Call logged with custom implementation',
|
|
207
|
+
messageType: 'success',
|
|
208
|
+
ttl: 2000
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
adapterRegistry.registerAdapterInterface('myCRM', 'createCallLog', customCreateCallLog);
|
|
214
|
+
```
|
|
215
|
+
|
|
128
216
|
#### `adapterRegistry.getAdapter(name)`
|
|
129
|
-
Retrieves a registered adapter.
|
|
217
|
+
Retrieves a registered adapter with composed interfaces.
|
|
218
|
+
|
|
219
|
+
**Parameters:**
|
|
220
|
+
- `name` (String): Adapter name
|
|
221
|
+
|
|
222
|
+
**Returns:** Composed adapter object or interface-only object
|
|
223
|
+
|
|
224
|
+
**Behavior:**
|
|
225
|
+
- If adapter exists and interfaces exist: Returns composed adapter with both
|
|
226
|
+
- If adapter exists but no interfaces: Returns original adapter
|
|
227
|
+
- If no adapter but interfaces exist: Returns object with just interface functions
|
|
228
|
+
- If no adapter and no interfaces: Throws error
|
|
229
|
+
|
|
230
|
+
#### `adapterRegistry.getOriginalAdapter(name)`
|
|
231
|
+
Retrieves the original adapter without any composed interface functions.
|
|
130
232
|
|
|
131
233
|
**Parameters:**
|
|
132
234
|
- `name` (String): Adapter name
|
|
133
235
|
|
|
134
|
-
**Returns:**
|
|
236
|
+
**Returns:** Original adapter object
|
|
237
|
+
|
|
238
|
+
#### `adapterRegistry.getPlatformInterfaces(platformName)`
|
|
239
|
+
Returns a Map of registered interface functions for a platform.
|
|
240
|
+
|
|
241
|
+
**Parameters:**
|
|
242
|
+
- `platformName` (String): Platform identifier
|
|
243
|
+
|
|
244
|
+
**Returns:** Map of interface functions
|
|
245
|
+
|
|
246
|
+
#### `adapterRegistry.hasPlatformInterface(platformName, interfaceName)`
|
|
247
|
+
Checks if a specific interface function is registered for a platform.
|
|
248
|
+
|
|
249
|
+
**Parameters:**
|
|
250
|
+
- `platformName` (String): Platform identifier
|
|
251
|
+
- `interfaceName` (String): Interface function name
|
|
252
|
+
|
|
253
|
+
**Returns:** Boolean indicating if interface exists
|
|
254
|
+
|
|
255
|
+
#### `adapterRegistry.unregisterAdapterInterface(platformName, interfaceName)`
|
|
256
|
+
Unregisters an interface function for a platform.
|
|
257
|
+
|
|
258
|
+
**Parameters:**
|
|
259
|
+
- `platformName` (String): Platform identifier
|
|
260
|
+
- `interfaceName` (String): Interface function name
|
|
261
|
+
|
|
262
|
+
#### `adapterRegistry.getAdapterCapabilities(platformName)`
|
|
263
|
+
Gets comprehensive information about an adapter including its capabilities and registered interfaces.
|
|
264
|
+
|
|
265
|
+
**Parameters:**
|
|
266
|
+
- `platformName` (String): Platform identifier
|
|
267
|
+
|
|
268
|
+
**Returns:** Object with adapter capabilities information
|
|
135
269
|
|
|
136
270
|
### Exported Components
|
|
137
271
|
|
|
@@ -226,16 +360,41 @@ The core package provides the following API endpoints:
|
|
|
226
360
|
|
|
227
361
|
The core package uses the following environment variables:
|
|
228
362
|
|
|
229
|
-
- `
|
|
363
|
+
- `DATABASE_URL` - Database connection string for Sequelize ORM
|
|
230
364
|
- `DISABLE_SYNC_DB_TABLE` - Skip database table synchronization
|
|
231
365
|
- `OVERRIDE_APP_SERVER` - Override app server URL in manifests
|
|
232
366
|
- `HASH_KEY` - Key for hashing user information
|
|
233
367
|
- `APP_SERVER_SECRET_KEY` - Server secret key
|
|
234
368
|
- `IS_PROD` - Production environment flag
|
|
369
|
+
- `DYNAMODB_LOCALHOST` - Local DynamoDB endpoint for development, used for lock cache
|
|
235
370
|
|
|
236
|
-
##
|
|
371
|
+
## Adapter Interface Registration Benefits
|
|
372
|
+
|
|
373
|
+
### Key Features
|
|
374
|
+
|
|
375
|
+
- **Composition over Mutation**: Interface functions are composed with adapters at retrieval time, preserving the original adapter
|
|
376
|
+
- **Dynamic Registration**: Register interface functions before or after adapter registration
|
|
377
|
+
- **Immutability**: Original adapter objects remain unchanged
|
|
378
|
+
- **Clean Separation**: Interface functions are kept separate from core adapter logic
|
|
379
|
+
- **Flexibility**: Support for interface-only adapters (no base adapter required)
|
|
237
380
|
|
|
238
|
-
|
|
381
|
+
### Best Practices
|
|
382
|
+
|
|
383
|
+
1. **Register Required Interfaces**: Register all required interface functions before using the adapter
|
|
384
|
+
2. **Use Descriptive Names**: Use clear, descriptive names for interface functions
|
|
385
|
+
3. **Handle Errors**: Implement proper error handling in interface functions
|
|
386
|
+
4. **Test Composed Adapters**: Test the final composed adapter to ensure interfaces work correctly
|
|
387
|
+
5. **Document Interfaces**: Document what each interface function does and expects
|
|
388
|
+
|
|
389
|
+
### Use Cases
|
|
390
|
+
|
|
391
|
+
- **Extending Existing Adapters**: Add new functionality to existing adapters without modification
|
|
392
|
+
- **Progressive Enhancement**: Start with interfaces, add base adapter later
|
|
393
|
+
- **Testing**: Test interface functions separately from base adapters
|
|
394
|
+
- **Modular Development**: Develop interface functions independently
|
|
395
|
+
- **Plugin Architecture**: Create pluggable interface functions for different scenarios
|
|
396
|
+
|
|
397
|
+
## Architecture
|
|
239
398
|
|
|
240
399
|
```
|
|
241
400
|
Core Package
|
package/adapter/registry.js
CHANGED
|
@@ -4,12 +4,67 @@ class AdapterRegistry {
|
|
|
4
4
|
this.adapters = new Map();
|
|
5
5
|
this.manifests = new Map();
|
|
6
6
|
this.releaseNotes = {};
|
|
7
|
+
this.platformInterfaces = new Map(); // Store interface functions per platform
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
setDefaultManifest(manifest) {
|
|
10
11
|
this.manifests.set('default', manifest);
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Register an interface function for a specific platform
|
|
16
|
+
* @param {string} platformName - Platform identifier (e.g., 'pipedrive', 'salesforce')
|
|
17
|
+
* @param {string} interfaceName - Interface function name (e.g., 'createCallLog', 'findContact')
|
|
18
|
+
* @param {Function} interfaceFunction - The interface function to register
|
|
19
|
+
*/
|
|
20
|
+
registerAdapterInterface(platformName, interfaceName, interfaceFunction) {
|
|
21
|
+
if (typeof interfaceFunction !== 'function') {
|
|
22
|
+
throw new Error(`Interface function must be a function, got: ${typeof interfaceFunction}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!this.platformInterfaces.has(platformName)) {
|
|
26
|
+
this.platformInterfaces.set(platformName, new Map());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const platformInterfaceMap = this.platformInterfaces.get(platformName);
|
|
30
|
+
platformInterfaceMap.set(interfaceName, interfaceFunction);
|
|
31
|
+
|
|
32
|
+
console.log(`Registered interface function: ${platformName}.${interfaceName}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get registered interface functions for a platform
|
|
37
|
+
* @param {string} platformName - Platform identifier
|
|
38
|
+
* @returns {Map} Map of interface functions
|
|
39
|
+
*/
|
|
40
|
+
getPlatformInterfaces(platformName) {
|
|
41
|
+
return this.platformInterfaces.get(platformName) || new Map();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if an interface function is registered for a platform
|
|
46
|
+
* @param {string} platformName - Platform identifier
|
|
47
|
+
* @param {string} interfaceName - Interface function name
|
|
48
|
+
* @returns {boolean} True if interface is registered
|
|
49
|
+
*/
|
|
50
|
+
hasPlatformInterface(platformName, interfaceName) {
|
|
51
|
+
const platformInterfaceMap = this.platformInterfaces.get(platformName);
|
|
52
|
+
return platformInterfaceMap ? platformInterfaceMap.has(interfaceName) : false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Unregister an interface function for a platform
|
|
57
|
+
* @param {string} platformName - Platform identifier
|
|
58
|
+
* @param {string} interfaceName - Interface function name
|
|
59
|
+
*/
|
|
60
|
+
unregisterAdapterInterface(platformName, interfaceName) {
|
|
61
|
+
const platformInterfaceMap = this.platformInterfaces.get(platformName);
|
|
62
|
+
if (platformInterfaceMap && platformInterfaceMap.has(interfaceName)) {
|
|
63
|
+
platformInterfaceMap.delete(interfaceName);
|
|
64
|
+
console.log(`Unregistered interface function: ${platformName}.${interfaceName}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
13
68
|
/**
|
|
14
69
|
* Register an adapter with the core system
|
|
15
70
|
* @param {string} platform - Platform identifier (e.g., 'pipedrive', 'salesforce')
|
|
@@ -29,16 +84,62 @@ class AdapterRegistry {
|
|
|
29
84
|
}
|
|
30
85
|
|
|
31
86
|
/**
|
|
32
|
-
* Get adapter by platform name
|
|
87
|
+
* Get adapter by platform name with composed interfaces
|
|
33
88
|
* @param {string} platform - Platform identifier
|
|
34
|
-
* @returns {Object}
|
|
89
|
+
* @returns {Object} Composed adapter with interface functions
|
|
35
90
|
*/
|
|
36
91
|
getAdapter(platform) {
|
|
37
92
|
const adapter = this.adapters.get(platform);
|
|
38
|
-
|
|
93
|
+
const platformInterfaceMap = this.platformInterfaces.get(platform);
|
|
94
|
+
|
|
95
|
+
// If no adapter and no interfaces, throw error
|
|
96
|
+
if (!adapter && (!platformInterfaceMap || platformInterfaceMap.size === 0)) {
|
|
39
97
|
throw new Error(`Adapter not found for platform: ${platform}`);
|
|
40
98
|
}
|
|
41
|
-
|
|
99
|
+
|
|
100
|
+
// If no adapter but interfaces exist, create a composed object with just interfaces
|
|
101
|
+
if (!adapter && platformInterfaceMap && platformInterfaceMap.size > 0) {
|
|
102
|
+
const composedAdapter = {};
|
|
103
|
+
|
|
104
|
+
// Add interface functions to the composed adapter
|
|
105
|
+
for (const [interfaceName, interfaceFunction] of platformInterfaceMap) {
|
|
106
|
+
composedAdapter[interfaceName] = interfaceFunction;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`Returning interface-only adapter for platform: ${platform}`);
|
|
110
|
+
return composedAdapter;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If adapter exists but no interfaces, return original adapter
|
|
114
|
+
if (adapter && (!platformInterfaceMap || platformInterfaceMap.size === 0)) {
|
|
115
|
+
return adapter;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// If both adapter and interfaces exist, create a composed object
|
|
119
|
+
const composedAdapter = Object.create(adapter);
|
|
120
|
+
|
|
121
|
+
// Add interface functions to the composed adapter
|
|
122
|
+
for (const [interfaceName, interfaceFunction] of platformInterfaceMap) {
|
|
123
|
+
// Only add if the interface doesn't already exist in the adapter
|
|
124
|
+
if (!Object.prototype.hasOwnProperty.call(adapter, interfaceName)) {
|
|
125
|
+
composedAdapter[interfaceName] = interfaceFunction;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return composedAdapter;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get the original adapter without composed interfaces
|
|
134
|
+
* @param {string} platform - Platform identifier
|
|
135
|
+
* @returns {Object} Original adapter implementation
|
|
136
|
+
*/
|
|
137
|
+
getOriginalAdapter(platform) {
|
|
138
|
+
const adapter = this.adapters.get(platform);
|
|
139
|
+
if (!adapter) {
|
|
140
|
+
throw new Error(`Adapter not found for platform: ${platform}`);
|
|
141
|
+
}
|
|
142
|
+
return adapter;
|
|
42
143
|
}
|
|
43
144
|
|
|
44
145
|
/**
|
|
@@ -98,6 +199,7 @@ class AdapterRegistry {
|
|
|
98
199
|
unregisterAdapter(platform) {
|
|
99
200
|
this.adapters.delete(platform);
|
|
100
201
|
this.manifests.delete(platform);
|
|
202
|
+
this.platformInterfaces.delete(platform);
|
|
101
203
|
console.log(`Unregistered adapter: ${platform}`);
|
|
102
204
|
}
|
|
103
205
|
|
|
@@ -108,8 +210,38 @@ class AdapterRegistry {
|
|
|
108
210
|
getReleaseNotes(platform) {
|
|
109
211
|
return this.releaseNotes;
|
|
110
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Get adapter capabilities summary including composed interfaces
|
|
216
|
+
* @param {string} platform - Platform identifier
|
|
217
|
+
* @returns {Object} Adapter capabilities
|
|
218
|
+
*/
|
|
219
|
+
getAdapterCapabilities(platform) {
|
|
220
|
+
const originalAdapter = this.getOriginalAdapter(platform);
|
|
221
|
+
const composedAdapter = this.getAdapter(platform);
|
|
222
|
+
const platformInterfaceMap = this.getPlatformInterfaces(platform);
|
|
223
|
+
|
|
224
|
+
const capabilities = {
|
|
225
|
+
platform,
|
|
226
|
+
originalMethods: Object.keys(originalAdapter),
|
|
227
|
+
composedMethods: Object.keys(composedAdapter),
|
|
228
|
+
registeredInterfaces: Array.from(platformInterfaceMap.keys()),
|
|
229
|
+
authType: null
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Get auth type if available
|
|
233
|
+
if (typeof originalAdapter.getAuthType === 'function') {
|
|
234
|
+
try {
|
|
235
|
+
capabilities.authType = originalAdapter.getAuthType();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
capabilities.authType = 'unknown';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return capabilities;
|
|
242
|
+
}
|
|
111
243
|
}
|
|
112
244
|
|
|
113
245
|
// Export singleton instance
|
|
114
246
|
const adapterRegistry = new AdapterRegistry();
|
|
115
|
-
module.exports = adapterRegistry;
|
|
247
|
+
module.exports = adapterRegistry;
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
// Test environment
|
|
5
|
+
testEnvironment: 'node',
|
|
6
|
+
|
|
7
|
+
// Test file patterns
|
|
8
|
+
testMatch: [
|
|
9
|
+
'<rootDir>/test/**/*.test.js',
|
|
10
|
+
'<rootDir>/**/*.test.js'
|
|
11
|
+
],
|
|
12
|
+
|
|
13
|
+
// Setup files
|
|
14
|
+
setupFilesAfterEnv: [
|
|
15
|
+
'<rootDir>/test/setup.js'
|
|
16
|
+
],
|
|
17
|
+
|
|
18
|
+
// Coverage configuration
|
|
19
|
+
collectCoverage: true,
|
|
20
|
+
coverageDirectory: '<rootDir>/coverage',
|
|
21
|
+
coverageReporters: ['text', 'lcov', 'html'],
|
|
22
|
+
coveragePathIgnorePatterns: [
|
|
23
|
+
'/node_modules/',
|
|
24
|
+
'/test/',
|
|
25
|
+
'/coverage/',
|
|
26
|
+
'jest.config.js',
|
|
27
|
+
'setup.js'
|
|
28
|
+
],
|
|
29
|
+
|
|
30
|
+
// Module resolution
|
|
31
|
+
moduleDirectories: ['node_modules', '<rootDir>'],
|
|
32
|
+
moduleNameMapper: {
|
|
33
|
+
'^@app-connect/core/(.*)$': '<rootDir>/$1'
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// Test timeout
|
|
37
|
+
testTimeout: 30000,
|
|
38
|
+
|
|
39
|
+
// Reporters
|
|
40
|
+
reporters: ['default'],
|
|
41
|
+
|
|
42
|
+
// Ignore patterns
|
|
43
|
+
modulePathIgnorePatterns: [
|
|
44
|
+
'<rootDir>/node_modules/',
|
|
45
|
+
'<rootDir>/coverage/',
|
|
46
|
+
'<rootDir>/test-results/'
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
// Clear mocks between tests
|
|
50
|
+
clearMocks: true,
|
|
51
|
+
|
|
52
|
+
// Restore mocks between tests
|
|
53
|
+
restoreMocks: true,
|
|
54
|
+
|
|
55
|
+
// Verbose output
|
|
56
|
+
verbose: true
|
|
57
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-connect/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "RingCentral App Connect Core",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
"tz-lookup": "^6.1.25",
|
|
36
36
|
"ua-parser-js": "^1.0.38"
|
|
37
37
|
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "jest",
|
|
40
|
+
"test:watch": "jest --watch",
|
|
41
|
+
"test:coverage": "jest --coverage",
|
|
42
|
+
"test:ci": "jest --ci --coverage --watchAll=false"
|
|
43
|
+
},
|
|
38
44
|
"devDependencies": {
|
|
39
45
|
"@eslint/js": "^9.22.0",
|
|
40
46
|
"@octokit/rest": "^19.0.5",
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const adapterRegistry = require('../../adapter/registry');
|
|
2
|
+
|
|
3
|
+
describe('AdapterRegistry Interface Registration with Composition', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
// Clear the registry before each test
|
|
6
|
+
adapterRegistry.adapters.clear();
|
|
7
|
+
adapterRegistry.manifests.clear();
|
|
8
|
+
adapterRegistry.platformInterfaces.clear();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('should register interface functions for a platform', () => {
|
|
12
|
+
const mockFunction = jest.fn();
|
|
13
|
+
|
|
14
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'testInterface', mockFunction);
|
|
15
|
+
|
|
16
|
+
expect(adapterRegistry.hasPlatformInterface('testPlatform', 'testInterface')).toBe(true);
|
|
17
|
+
expect(adapterRegistry.getPlatformInterfaces('testPlatform').get('testInterface')).toBe(mockFunction);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('should throw error when registering non-function as interface', () => {
|
|
21
|
+
expect(() => {
|
|
22
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'testInterface', 'not a function');
|
|
23
|
+
}).toThrow('Interface function must be a function, got: string');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('should return original adapter when no interfaces are registered', () => {
|
|
27
|
+
const mockAdapter = {
|
|
28
|
+
getAuthType: () => 'apiKey',
|
|
29
|
+
createCallLog: jest.fn(),
|
|
30
|
+
updateCallLog: jest.fn()
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
34
|
+
|
|
35
|
+
const retrievedAdapter = adapterRegistry.getAdapter('testPlatform');
|
|
36
|
+
expect(retrievedAdapter).toBe(mockAdapter);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('should return composed adapter with interface functions when interfaces are registered', () => {
|
|
40
|
+
const mockInterface = jest.fn();
|
|
41
|
+
const mockAdapter = {
|
|
42
|
+
getAuthType: () => 'apiKey',
|
|
43
|
+
createCallLog: jest.fn(),
|
|
44
|
+
updateCallLog: jest.fn()
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Register interface function first
|
|
48
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'customMethod', mockInterface);
|
|
49
|
+
|
|
50
|
+
// Register adapter
|
|
51
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
52
|
+
|
|
53
|
+
// Get composed adapter
|
|
54
|
+
const composedAdapter = adapterRegistry.getAdapter('testPlatform');
|
|
55
|
+
|
|
56
|
+
// Should be a different object (composed)
|
|
57
|
+
expect(composedAdapter).not.toBe(mockAdapter);
|
|
58
|
+
|
|
59
|
+
// Should have the interface function
|
|
60
|
+
expect(composedAdapter.customMethod).toBe(mockInterface);
|
|
61
|
+
|
|
62
|
+
// Should still have original methods
|
|
63
|
+
expect(composedAdapter.getAuthType).toBe(mockAdapter.getAuthType);
|
|
64
|
+
expect(composedAdapter.createCallLog).toBe(mockAdapter.createCallLog);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should not override existing adapter methods when composing interfaces', () => {
|
|
68
|
+
const existingMethod = jest.fn();
|
|
69
|
+
const mockAdapter = {
|
|
70
|
+
getAuthType: () => 'apiKey',
|
|
71
|
+
createCallLog: jest.fn(),
|
|
72
|
+
updateCallLog: jest.fn(),
|
|
73
|
+
existingMethod: existingMethod
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Register adapter first
|
|
77
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
78
|
+
|
|
79
|
+
// Try to register interface with same name as existing method
|
|
80
|
+
const newMethod = jest.fn();
|
|
81
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'existingMethod', newMethod);
|
|
82
|
+
|
|
83
|
+
// Get composed adapter
|
|
84
|
+
const composedAdapter = adapterRegistry.getAdapter('testPlatform');
|
|
85
|
+
|
|
86
|
+
// Should not override the existing method
|
|
87
|
+
expect(composedAdapter.existingMethod).toBe(existingMethod);
|
|
88
|
+
expect(composedAdapter.existingMethod).not.toBe(newMethod);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('should preserve original adapter when composing interfaces', () => {
|
|
92
|
+
const mockInterface = jest.fn();
|
|
93
|
+
const mockAdapter = {
|
|
94
|
+
getAuthType: () => 'apiKey',
|
|
95
|
+
createCallLog: jest.fn(),
|
|
96
|
+
updateCallLog: jest.fn()
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'customMethod', mockInterface);
|
|
100
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
101
|
+
|
|
102
|
+
// Get original adapter
|
|
103
|
+
const originalAdapter = adapterRegistry.getOriginalAdapter('testPlatform');
|
|
104
|
+
|
|
105
|
+
// Original adapter should be unchanged
|
|
106
|
+
expect(originalAdapter).toBe(mockAdapter);
|
|
107
|
+
expect(originalAdapter.customMethod).toBeUndefined();
|
|
108
|
+
|
|
109
|
+
// Composed adapter should have the interface
|
|
110
|
+
const composedAdapter = adapterRegistry.getAdapter('testPlatform');
|
|
111
|
+
expect(composedAdapter.customMethod).toBe(mockInterface);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('should unregister interface functions', () => {
|
|
115
|
+
const mockFunction = jest.fn();
|
|
116
|
+
|
|
117
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'testInterface', mockFunction);
|
|
118
|
+
expect(adapterRegistry.hasPlatformInterface('testPlatform', 'testInterface')).toBe(true);
|
|
119
|
+
|
|
120
|
+
adapterRegistry.unregisterAdapterInterface('testPlatform', 'testInterface');
|
|
121
|
+
expect(adapterRegistry.hasPlatformInterface('testPlatform', 'testInterface')).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('should return empty map for non-existent platform interfaces', () => {
|
|
125
|
+
const interfaces = adapterRegistry.getPlatformInterfaces('nonExistentPlatform');
|
|
126
|
+
expect(interfaces).toBeInstanceOf(Map);
|
|
127
|
+
expect(interfaces.size).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('should return false for non-existent platform interface', () => {
|
|
131
|
+
expect(adapterRegistry.hasPlatformInterface('nonExistentPlatform', 'anyInterface')).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('should handle multiple interface functions for same platform', () => {
|
|
135
|
+
const mockFunction1 = jest.fn();
|
|
136
|
+
const mockFunction2 = jest.fn();
|
|
137
|
+
const mockAdapter = {
|
|
138
|
+
getAuthType: () => 'apiKey',
|
|
139
|
+
createCallLog: jest.fn(),
|
|
140
|
+
updateCallLog: jest.fn()
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'interface1', mockFunction1);
|
|
144
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'interface2', mockFunction2);
|
|
145
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
146
|
+
|
|
147
|
+
const platformInterfaces = adapterRegistry.getPlatformInterfaces('testPlatform');
|
|
148
|
+
expect(platformInterfaces.size).toBe(2);
|
|
149
|
+
expect(platformInterfaces.get('interface1')).toBe(mockFunction1);
|
|
150
|
+
expect(platformInterfaces.get('interface2')).toBe(mockFunction2);
|
|
151
|
+
|
|
152
|
+
// Check composed adapter has both interfaces
|
|
153
|
+
const composedAdapter = adapterRegistry.getAdapter('testPlatform');
|
|
154
|
+
expect(composedAdapter.interface1).toBe(mockFunction1);
|
|
155
|
+
expect(composedAdapter.interface2).toBe(mockFunction2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('should clean up platform interfaces when unregistering adapter', () => {
|
|
159
|
+
const mockFunction = jest.fn();
|
|
160
|
+
const mockAdapter = {
|
|
161
|
+
getAuthType: () => 'apiKey',
|
|
162
|
+
createCallLog: jest.fn(),
|
|
163
|
+
updateCallLog: jest.fn()
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'testInterface', mockFunction);
|
|
167
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
168
|
+
|
|
169
|
+
expect(adapterRegistry.hasPlatformInterface('testPlatform', 'testInterface')).toBe(true);
|
|
170
|
+
|
|
171
|
+
adapterRegistry.unregisterAdapter('testPlatform');
|
|
172
|
+
|
|
173
|
+
expect(adapterRegistry.hasPlatformInterface('testPlatform', 'testInterface')).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('should get adapter capabilities correctly', () => {
|
|
177
|
+
const mockInterface = jest.fn();
|
|
178
|
+
const mockAdapter = {
|
|
179
|
+
getAuthType: () => 'apiKey',
|
|
180
|
+
createCallLog: jest.fn(),
|
|
181
|
+
updateCallLog: jest.fn()
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'customMethod', mockInterface);
|
|
185
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
186
|
+
|
|
187
|
+
const capabilities = adapterRegistry.getAdapterCapabilities('testPlatform');
|
|
188
|
+
|
|
189
|
+
expect(capabilities.platform).toBe('testPlatform');
|
|
190
|
+
expect(capabilities.originalMethods).toContain('getAuthType');
|
|
191
|
+
expect(capabilities.originalMethods).toContain('createCallLog');
|
|
192
|
+
expect(capabilities.originalMethods).toContain('updateCallLog');
|
|
193
|
+
expect(capabilities.composedMethods).toContain('customMethod');
|
|
194
|
+
expect(capabilities.registeredInterfaces).toContain('customMethod');
|
|
195
|
+
expect(capabilities.authType).toBe('apiKey');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('should handle interface registration after adapter registration', () => {
|
|
199
|
+
const mockAdapter = {
|
|
200
|
+
getAuthType: () => 'apiKey',
|
|
201
|
+
createCallLog: jest.fn(),
|
|
202
|
+
updateCallLog: jest.fn()
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Register adapter first
|
|
206
|
+
adapterRegistry.registerAdapter('testPlatform', mockAdapter);
|
|
207
|
+
|
|
208
|
+
// Register interface function after
|
|
209
|
+
const mockInterface = jest.fn();
|
|
210
|
+
adapterRegistry.registerAdapterInterface('testPlatform', 'customMethod', mockInterface);
|
|
211
|
+
|
|
212
|
+
// Get composed adapter
|
|
213
|
+
const composedAdapter = adapterRegistry.getAdapter('testPlatform');
|
|
214
|
+
|
|
215
|
+
// Should have the interface function
|
|
216
|
+
expect(composedAdapter.customMethod).toBe(mockInterface);
|
|
217
|
+
|
|
218
|
+
// Original adapter should be unchanged
|
|
219
|
+
const originalAdapter = adapterRegistry.getOriginalAdapter('testPlatform');
|
|
220
|
+
expect(originalAdapter.customMethod).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('should return interface-only adapter when no base adapter is registered', () => {
|
|
224
|
+
const mockInterface1 = jest.fn();
|
|
225
|
+
const mockInterface2 = jest.fn();
|
|
226
|
+
|
|
227
|
+
// Register only interface functions, no base adapter
|
|
228
|
+
adapterRegistry.registerAdapterInterface('interfaceOnlyPlatform', 'method1', mockInterface1);
|
|
229
|
+
adapterRegistry.registerAdapterInterface('interfaceOnlyPlatform', 'method2', mockInterface2);
|
|
230
|
+
|
|
231
|
+
// Get adapter - should return interface-only object
|
|
232
|
+
const interfaceOnlyAdapter = adapterRegistry.getAdapter('interfaceOnlyPlatform');
|
|
233
|
+
|
|
234
|
+
// Should have interface functions
|
|
235
|
+
expect(interfaceOnlyAdapter.method1).toBe(mockInterface1);
|
|
236
|
+
expect(interfaceOnlyAdapter.method2).toBe(mockInterface2);
|
|
237
|
+
|
|
238
|
+
// Should not have base adapter methods
|
|
239
|
+
expect(interfaceOnlyAdapter.getAuthType).toBeUndefined();
|
|
240
|
+
|
|
241
|
+
// Should be a plain object, not inherited from any adapter
|
|
242
|
+
expect(Object.getPrototypeOf(interfaceOnlyAdapter)).toBe(Object.prototype);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('should throw error when no adapter and no interfaces are registered', () => {
|
|
246
|
+
expect(() => {
|
|
247
|
+
adapterRegistry.getAdapter('nonExistentPlatform');
|
|
248
|
+
}).toThrow('Adapter not found for platform: nonExistentPlatform');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('should handle mixed scenarios correctly', () => {
|
|
252
|
+
// Scenario 1: Only interfaces, no adapter
|
|
253
|
+
adapterRegistry.registerAdapterInterface('mixedPlatform', 'interfaceMethod', jest.fn());
|
|
254
|
+
const interfaceOnly = adapterRegistry.getAdapter('mixedPlatform');
|
|
255
|
+
expect(interfaceOnly.interfaceMethod).toBeDefined();
|
|
256
|
+
expect(interfaceOnly.getAuthType).toBeUndefined();
|
|
257
|
+
|
|
258
|
+
// Scenario 2: Add adapter later
|
|
259
|
+
const mockAdapter = {
|
|
260
|
+
getAuthType: () => 'apiKey',
|
|
261
|
+
createCallLog: jest.fn(),
|
|
262
|
+
updateCallLog: jest.fn()
|
|
263
|
+
};
|
|
264
|
+
adapterRegistry.registerAdapter('mixedPlatform', mockAdapter);
|
|
265
|
+
|
|
266
|
+
const composedAdapter = adapterRegistry.getAdapter('mixedPlatform');
|
|
267
|
+
expect(composedAdapter.interfaceMethod).toBeDefined();
|
|
268
|
+
expect(composedAdapter.getAuthType).toBeDefined();
|
|
269
|
+
expect(composedAdapter.getAuthType()).toBe('apiKey');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const authHandler = require('../../handlers/auth');
|
|
2
|
+
const adapterRegistry = require('../../adapter/registry');
|
|
3
|
+
|
|
4
|
+
// Mock the adapter registry
|
|
5
|
+
jest.mock('../../adapter/registry');
|
|
6
|
+
|
|
7
|
+
describe('Auth Handler', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Reset mocks
|
|
10
|
+
jest.clearAllMocks();
|
|
11
|
+
global.testUtils.resetAdapterRegistry();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('onApiKeyLogin', () => {
|
|
15
|
+
test('should handle successful API key login', async () => {
|
|
16
|
+
// Arrange
|
|
17
|
+
const mockUserInfo = {
|
|
18
|
+
successful: true,
|
|
19
|
+
platformUserInfo: {
|
|
20
|
+
id: 'test-user-id',
|
|
21
|
+
name: 'Test User',
|
|
22
|
+
timezoneName: 'America/Los_Angeles',
|
|
23
|
+
timezoneOffset: 0,
|
|
24
|
+
platformAdditionalInfo: {}
|
|
25
|
+
},
|
|
26
|
+
returnMessage: {
|
|
27
|
+
messageType: 'success',
|
|
28
|
+
message: 'Login successful',
|
|
29
|
+
ttl: 1000
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const mockAdapter = global.testUtils.createMockAdapter({
|
|
34
|
+
getBasicAuth: jest.fn().mockReturnValue('dGVzdC1hcGkta2V5Og=='),
|
|
35
|
+
getUserInfo: jest.fn().mockResolvedValue(mockUserInfo)
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
adapterRegistry.getAdapter.mockReturnValue(mockAdapter);
|
|
39
|
+
|
|
40
|
+
const requestData = {
|
|
41
|
+
platform: 'testCRM',
|
|
42
|
+
hostname: 'test.example.com',
|
|
43
|
+
apiKey: 'test-api-key',
|
|
44
|
+
additionalInfo: {}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Act
|
|
48
|
+
const result = await authHandler.onApiKeyLogin(requestData);
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
51
|
+
expect(result.userInfo).toBeDefined();
|
|
52
|
+
expect(result.userInfo.id).toBe('test-user-id');
|
|
53
|
+
expect(result.userInfo.name).toBe('Test User');
|
|
54
|
+
expect(result.returnMessage).toEqual(mockUserInfo.returnMessage);
|
|
55
|
+
expect(mockAdapter.getBasicAuth).toHaveBeenCalledWith({ apiKey: 'test-api-key' });
|
|
56
|
+
expect(mockAdapter.getUserInfo).toHaveBeenCalledWith({
|
|
57
|
+
authHeader: 'Basic dGVzdC1hcGkta2V5Og==',
|
|
58
|
+
hostname: 'test.example.com',
|
|
59
|
+
additionalInfo: {},
|
|
60
|
+
apiKey: 'test-api-key'
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('should handle failed API key login', async () => {
|
|
65
|
+
// Arrange
|
|
66
|
+
const mockUserInfo = {
|
|
67
|
+
successful: false,
|
|
68
|
+
platformUserInfo: null,
|
|
69
|
+
returnMessage: {
|
|
70
|
+
messageType: 'error',
|
|
71
|
+
message: 'Invalid API key',
|
|
72
|
+
ttl: 3000
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const mockAdapter = global.testUtils.createMockAdapter({
|
|
77
|
+
getBasicAuth: jest.fn().mockReturnValue('dGVzdC1hcGkta2V5Og=='),
|
|
78
|
+
getUserInfo: jest.fn().mockResolvedValue(mockUserInfo)
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
adapterRegistry.getAdapter.mockReturnValue(mockAdapter);
|
|
82
|
+
|
|
83
|
+
const requestData = {
|
|
84
|
+
platform: 'testCRM',
|
|
85
|
+
hostname: 'test.example.com',
|
|
86
|
+
apiKey: 'invalid-api-key',
|
|
87
|
+
additionalInfo: {}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Act
|
|
91
|
+
const result = await authHandler.onApiKeyLogin(requestData);
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
94
|
+
expect(result.userInfo).toBeNull();
|
|
95
|
+
expect(result.returnMessage).toEqual(mockUserInfo.returnMessage);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should throw error when adapter not found', async () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
adapterRegistry.getAdapter.mockImplementation(() => {
|
|
101
|
+
throw new Error('Adapter not found for platform: testCRM');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const requestData = {
|
|
105
|
+
platform: 'testCRM',
|
|
106
|
+
hostname: 'test.example.com',
|
|
107
|
+
apiKey: 'test-api-key',
|
|
108
|
+
additionalInfo: {}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Act & Assert
|
|
112
|
+
await expect(authHandler.onApiKeyLogin(requestData))
|
|
113
|
+
.rejects.toThrow('Adapter not found for platform: testCRM');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('authValidation', () => {
|
|
118
|
+
test('should validate user authentication successfully', async () => {
|
|
119
|
+
// Arrange
|
|
120
|
+
const mockUser = global.testUtils.createMockUser();
|
|
121
|
+
const mockValidationResponse = {
|
|
122
|
+
successful: true,
|
|
123
|
+
returnMessage: {
|
|
124
|
+
messageType: 'success',
|
|
125
|
+
message: 'Authentication valid',
|
|
126
|
+
ttl: 1000
|
|
127
|
+
},
|
|
128
|
+
status: 200
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const mockAdapter = global.testUtils.createMockAdapter({
|
|
132
|
+
getOauthInfo: jest.fn().mockResolvedValue({}),
|
|
133
|
+
authValidation: jest.fn().mockResolvedValue(mockValidationResponse)
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
adapterRegistry.getAdapter.mockReturnValue(mockAdapter);
|
|
137
|
+
|
|
138
|
+
// Mock UserModel.findOne to return a user
|
|
139
|
+
const { UserModel } = require('../../models/userModel');
|
|
140
|
+
jest.spyOn(UserModel, 'findOne').mockResolvedValue(mockUser);
|
|
141
|
+
|
|
142
|
+
// Mock oauth.checkAndRefreshAccessToken
|
|
143
|
+
const oauth = require('../../lib/oauth');
|
|
144
|
+
jest.spyOn(oauth, 'checkAndRefreshAccessToken').mockResolvedValue(mockUser);
|
|
145
|
+
|
|
146
|
+
const requestData = {
|
|
147
|
+
platform: 'testCRM',
|
|
148
|
+
userId: 'test-user-id'
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Act
|
|
152
|
+
const result = await authHandler.authValidation(requestData);
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(result).toEqual({
|
|
156
|
+
...mockValidationResponse,
|
|
157
|
+
failReason: ''
|
|
158
|
+
});
|
|
159
|
+
expect(mockAdapter.authValidation).toHaveBeenCalledWith({ user: mockUser });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('should handle user not found in database', async () => {
|
|
163
|
+
// Arrange
|
|
164
|
+
const mockAdapter = global.testUtils.createMockAdapter();
|
|
165
|
+
adapterRegistry.getAdapter.mockReturnValue(mockAdapter);
|
|
166
|
+
|
|
167
|
+
// Mock UserModel.findOne to return null (user not found)
|
|
168
|
+
const { UserModel } = require('../../models/userModel');
|
|
169
|
+
jest.spyOn(UserModel, 'findOne').mockResolvedValue(null);
|
|
170
|
+
|
|
171
|
+
const requestData = {
|
|
172
|
+
platform: 'testCRM',
|
|
173
|
+
userId: 'non-existent-user'
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Act
|
|
177
|
+
const result = await authHandler.authValidation(requestData);
|
|
178
|
+
|
|
179
|
+
// Assert
|
|
180
|
+
expect(result).toEqual({
|
|
181
|
+
successful: false,
|
|
182
|
+
status: 404,
|
|
183
|
+
failReason: 'App Connect. User not found in database'
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('should handle validation failure', async () => {
|
|
188
|
+
// Arrange
|
|
189
|
+
const mockUser = global.testUtils.createMockUser();
|
|
190
|
+
const mockValidationResponse = {
|
|
191
|
+
successful: false,
|
|
192
|
+
returnMessage: {
|
|
193
|
+
messageType: 'error',
|
|
194
|
+
message: 'Authentication failed',
|
|
195
|
+
ttl: 3000
|
|
196
|
+
},
|
|
197
|
+
status: 401
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const mockAdapter = global.testUtils.createMockAdapter({
|
|
201
|
+
getOauthInfo: jest.fn().mockResolvedValue({}),
|
|
202
|
+
authValidation: jest.fn().mockResolvedValue(mockValidationResponse)
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
adapterRegistry.getAdapter.mockReturnValue(mockAdapter);
|
|
206
|
+
|
|
207
|
+
// Mock UserModel.findOne to return a user
|
|
208
|
+
const { UserModel } = require('../../models/userModel');
|
|
209
|
+
jest.spyOn(UserModel, 'findOne').mockResolvedValue(mockUser);
|
|
210
|
+
|
|
211
|
+
// Mock oauth.checkAndRefreshAccessToken
|
|
212
|
+
const oauth = require('../../lib/oauth');
|
|
213
|
+
jest.spyOn(oauth, 'checkAndRefreshAccessToken').mockResolvedValue(mockUser);
|
|
214
|
+
|
|
215
|
+
const requestData = {
|
|
216
|
+
platform: 'testCRM',
|
|
217
|
+
userId: 'test-user-id'
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Act
|
|
221
|
+
const result = await authHandler.authValidation(requestData);
|
|
222
|
+
|
|
223
|
+
// Assert
|
|
224
|
+
expect(result).toEqual({
|
|
225
|
+
...mockValidationResponse,
|
|
226
|
+
failReason: 'CRM. API failed'
|
|
227
|
+
});
|
|
228
|
+
expect(result.successful).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const jwt = require('../../lib/jwt');
|
|
2
|
+
|
|
3
|
+
describe('JWT Utility', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
// Reset environment
|
|
6
|
+
process.env.APP_SERVER_SECRET_KEY = 'test-secret-key';
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('generateJwt', () => {
|
|
10
|
+
test('should generate JWT token from payload', () => {
|
|
11
|
+
// Arrange
|
|
12
|
+
const payload = {
|
|
13
|
+
id: 'test-user-id',
|
|
14
|
+
platform: 'testCRM'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Act
|
|
18
|
+
const token = jwt.generateJwt(payload);
|
|
19
|
+
|
|
20
|
+
// Assert
|
|
21
|
+
expect(token).toBeDefined();
|
|
22
|
+
expect(typeof token).toBe('string');
|
|
23
|
+
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('should generate different tokens for different payloads', () => {
|
|
27
|
+
// Arrange
|
|
28
|
+
const payload1 = { id: 'user1', platform: 'testCRM' };
|
|
29
|
+
const payload2 = { id: 'user2', platform: 'testCRM' };
|
|
30
|
+
|
|
31
|
+
// Act
|
|
32
|
+
const token1 = jwt.generateJwt(payload1);
|
|
33
|
+
const token2 = jwt.generateJwt(payload2);
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(token1).not.toBe(token2);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('decodeJwt', () => {
|
|
41
|
+
test('should decode valid JWT token', () => {
|
|
42
|
+
// Arrange
|
|
43
|
+
const payload = {
|
|
44
|
+
id: 'test-user-id',
|
|
45
|
+
platform: 'testCRM'
|
|
46
|
+
};
|
|
47
|
+
const token = jwt.generateJwt(payload);
|
|
48
|
+
|
|
49
|
+
// Act
|
|
50
|
+
const decoded = jwt.decodeJwt(token);
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(decoded).toMatchObject(payload);
|
|
54
|
+
expect(decoded).toHaveProperty('exp');
|
|
55
|
+
expect(decoded).toHaveProperty('iat');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('should return null for invalid token', () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const invalidToken = 'invalid.jwt.token';
|
|
61
|
+
|
|
62
|
+
// Act
|
|
63
|
+
const decoded = jwt.decodeJwt(invalidToken);
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
66
|
+
expect(decoded).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('should return null for malformed token', () => {
|
|
70
|
+
// Arrange
|
|
71
|
+
const malformedToken = 'not-a-jwt-token';
|
|
72
|
+
|
|
73
|
+
// Act
|
|
74
|
+
const decoded = jwt.decodeJwt(malformedToken);
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
expect(decoded).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('should return null for token with wrong secret', () => {
|
|
81
|
+
// Arrange
|
|
82
|
+
const payload = { id: 'test-user-id', platform: 'testCRM' };
|
|
83
|
+
const token = jwt.generateJwt(payload);
|
|
84
|
+
|
|
85
|
+
// Change secret temporarily
|
|
86
|
+
const originalSecret = process.env.APP_SERVER_SECRET_KEY;
|
|
87
|
+
process.env.APP_SERVER_SECRET_KEY = 'different-secret';
|
|
88
|
+
|
|
89
|
+
// Act
|
|
90
|
+
const decoded = jwt.decodeJwt(token);
|
|
91
|
+
|
|
92
|
+
// Restore secret
|
|
93
|
+
process.env.APP_SERVER_SECRET_KEY = originalSecret;
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(decoded).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('generateJwt and decodeJwt round trip', () => {
|
|
101
|
+
test('should successfully generate and decode complex payload', () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
const complexPayload = {
|
|
104
|
+
id: 'test-user-id',
|
|
105
|
+
platform: 'testCRM',
|
|
106
|
+
timestamp: Date.now(),
|
|
107
|
+
metadata: {
|
|
108
|
+
timezone: 'America/Los_Angeles',
|
|
109
|
+
preferences: {
|
|
110
|
+
autoLog: true,
|
|
111
|
+
callPop: false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Act
|
|
117
|
+
const token = jwt.generateJwt(complexPayload);
|
|
118
|
+
const decoded = jwt.decodeJwt(token);
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
expect(decoded).toMatchObject(complexPayload);
|
|
122
|
+
expect(decoded).toHaveProperty('exp');
|
|
123
|
+
expect(decoded).toHaveProperty('iat');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('should handle empty payload', () => {
|
|
127
|
+
// Arrange
|
|
128
|
+
const emptyPayload = {};
|
|
129
|
+
|
|
130
|
+
// Act
|
|
131
|
+
const token = jwt.generateJwt(emptyPayload);
|
|
132
|
+
const decoded = jwt.decodeJwt(token);
|
|
133
|
+
|
|
134
|
+
// Assert
|
|
135
|
+
expect(decoded).toMatchObject(emptyPayload);
|
|
136
|
+
expect(decoded).toHaveProperty('exp');
|
|
137
|
+
expect(decoded).toHaveProperty('iat');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('error handling', () => {
|
|
142
|
+
test('should handle missing secret key', () => {
|
|
143
|
+
// Arrange
|
|
144
|
+
const payload = { id: 'test-user-id' };
|
|
145
|
+
delete process.env.APP_SERVER_SECRET_KEY;
|
|
146
|
+
|
|
147
|
+
// Act & Assert
|
|
148
|
+
expect(() => jwt.generateJwt(payload)).toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('should handle null payload', () => {
|
|
152
|
+
// Act & Assert
|
|
153
|
+
expect(() => jwt.generateJwt(null)).toThrow();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('should handle undefined payload', () => {
|
|
157
|
+
// Act & Assert
|
|
158
|
+
expect(() => jwt.generateJwt(undefined)).toThrow();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
package/test/setup.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Test setup for @app-connect/core package
|
|
2
|
+
const path = require('path');
|
|
3
|
+
require('dotenv').config({ path: path.resolve(__dirname, '../.env.test') });
|
|
4
|
+
|
|
5
|
+
// Set test timeout
|
|
6
|
+
jest.setTimeout(30000);
|
|
7
|
+
|
|
8
|
+
// Mock console methods to reduce noise in tests
|
|
9
|
+
global.console = {
|
|
10
|
+
...console,
|
|
11
|
+
log: jest.fn(),
|
|
12
|
+
debug: jest.fn(),
|
|
13
|
+
info: jest.fn(),
|
|
14
|
+
warn: jest.fn(),
|
|
15
|
+
error: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Setup database models for testing
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
try {
|
|
21
|
+
// Set up test database URL if not provided
|
|
22
|
+
if (!process.env.DATABASE_URL) {
|
|
23
|
+
process.env.DATABASE_URL = 'sqlite::memory:';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Import models
|
|
27
|
+
const { CallLogModel } = require('../models/callLogModel');
|
|
28
|
+
const { MessageLogModel } = require('../models/messageLogModel');
|
|
29
|
+
const { UserModel } = require('../models/userModel');
|
|
30
|
+
const { CacheModel } = require('../models/cacheModel');
|
|
31
|
+
const { AdminConfigModel } = require('../models/adminConfigModel');
|
|
32
|
+
|
|
33
|
+
// Sync database models
|
|
34
|
+
await CallLogModel.sync({ force: true });
|
|
35
|
+
await MessageLogModel.sync({ force: true });
|
|
36
|
+
await UserModel.sync({ force: true });
|
|
37
|
+
await CacheModel.sync({ force: true });
|
|
38
|
+
await AdminConfigModel.sync({ force: true });
|
|
39
|
+
|
|
40
|
+
console.log('Database models synced for testing');
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Error setting up test database:', error);
|
|
43
|
+
// Don't fail the setup, some tests might not need database
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Clean up after all tests
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
try {
|
|
50
|
+
// Close database connections
|
|
51
|
+
const { sequelize } = require('../models/sequelize');
|
|
52
|
+
if (sequelize) {
|
|
53
|
+
await sequelize.close();
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Error closing database connection:', error);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Global test utilities
|
|
61
|
+
global.testUtils = {
|
|
62
|
+
// Helper to create mock user
|
|
63
|
+
createMockUser: (overrides = {}) => ({
|
|
64
|
+
id: 'test-user-id',
|
|
65
|
+
platform: 'testCRM',
|
|
66
|
+
accessToken: 'test-access-token',
|
|
67
|
+
refreshToken: 'test-refresh-token',
|
|
68
|
+
tokenExpiry: new Date(Date.now() + 3600000), // 1 hour from now
|
|
69
|
+
platformUserInfo: {
|
|
70
|
+
id: 'test-platform-user-id',
|
|
71
|
+
name: 'Test User',
|
|
72
|
+
timezoneName: 'America/Los_Angeles',
|
|
73
|
+
timezoneOffset: 0,
|
|
74
|
+
platformAdditionalInfo: {}
|
|
75
|
+
},
|
|
76
|
+
...overrides
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
// Helper to create mock call log
|
|
80
|
+
createMockCallLog: (overrides = {}) => ({
|
|
81
|
+
id: 'test-call-log-id',
|
|
82
|
+
userId: 'test-user-id',
|
|
83
|
+
platform: 'testCRM',
|
|
84
|
+
thirdPartyLogId: 'test-third-party-id',
|
|
85
|
+
contactId: 'test-contact-id',
|
|
86
|
+
contactType: 'Contact',
|
|
87
|
+
phoneNumber: '+1234567890',
|
|
88
|
+
callDirection: 'Inbound',
|
|
89
|
+
callResult: 'Answered',
|
|
90
|
+
callDuration: 120,
|
|
91
|
+
callStartTime: new Date(),
|
|
92
|
+
callEndTime: new Date(Date.now() + 120000),
|
|
93
|
+
recordingLink: 'https://example.com/recording.mp3',
|
|
94
|
+
subject: 'Test Call',
|
|
95
|
+
note: 'Test call note',
|
|
96
|
+
...overrides
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
// Helper to create mock contact
|
|
100
|
+
createMockContact: (overrides = {}) => ({
|
|
101
|
+
id: 'test-contact-id',
|
|
102
|
+
name: 'Test Contact',
|
|
103
|
+
type: 'Contact',
|
|
104
|
+
phone: '+1234567890',
|
|
105
|
+
additionalInfo: null,
|
|
106
|
+
...overrides
|
|
107
|
+
}),
|
|
108
|
+
|
|
109
|
+
// Helper to reset adapter registry
|
|
110
|
+
resetAdapterRegistry: () => {
|
|
111
|
+
const adapterRegistry = require('../adapter/registry');
|
|
112
|
+
adapterRegistry.adapters.clear();
|
|
113
|
+
adapterRegistry.manifests.clear();
|
|
114
|
+
adapterRegistry.platformInterfaces.clear();
|
|
115
|
+
adapterRegistry.releaseNotes = {};
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// Helper to create mock adapter
|
|
119
|
+
createMockAdapter: (overrides = {}) => ({
|
|
120
|
+
getAuthType: jest.fn().mockReturnValue('apiKey'),
|
|
121
|
+
getUserInfo: jest.fn().mockResolvedValue({
|
|
122
|
+
successful: true,
|
|
123
|
+
platformUserInfo: {
|
|
124
|
+
id: 'test-user-id',
|
|
125
|
+
name: 'Test User',
|
|
126
|
+
timezoneName: 'America/Los_Angeles',
|
|
127
|
+
timezoneOffset: 0,
|
|
128
|
+
platformAdditionalInfo: {}
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
131
|
+
createCallLog: jest.fn().mockResolvedValue({
|
|
132
|
+
logId: 'test-log-id',
|
|
133
|
+
returnMessage: {
|
|
134
|
+
message: 'Call logged successfully',
|
|
135
|
+
messageType: 'success',
|
|
136
|
+
ttl: 2000
|
|
137
|
+
}
|
|
138
|
+
}),
|
|
139
|
+
updateCallLog: jest.fn().mockResolvedValue({
|
|
140
|
+
updatedNote: 'Call log updated',
|
|
141
|
+
returnMessage: {
|
|
142
|
+
message: 'Call log updated successfully',
|
|
143
|
+
messageType: 'success',
|
|
144
|
+
ttl: 2000
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
unAuthorize: jest.fn().mockResolvedValue({
|
|
148
|
+
returnMessage: {
|
|
149
|
+
messageType: 'success',
|
|
150
|
+
message: 'Logged out successfully',
|
|
151
|
+
ttl: 1000
|
|
152
|
+
}
|
|
153
|
+
}),
|
|
154
|
+
findContact: jest.fn().mockResolvedValue([
|
|
155
|
+
{
|
|
156
|
+
id: 'test-contact-id',
|
|
157
|
+
name: 'Test Contact',
|
|
158
|
+
type: 'Contact',
|
|
159
|
+
phone: '+1234567890',
|
|
160
|
+
additionalInfo: null
|
|
161
|
+
}
|
|
162
|
+
]),
|
|
163
|
+
createContact: jest.fn().mockResolvedValue({
|
|
164
|
+
contactInfo: {
|
|
165
|
+
id: 'new-contact-id',
|
|
166
|
+
name: 'New Contact'
|
|
167
|
+
},
|
|
168
|
+
returnMessage: {
|
|
169
|
+
message: 'Contact created successfully',
|
|
170
|
+
messageType: 'success',
|
|
171
|
+
ttl: 2000
|
|
172
|
+
}
|
|
173
|
+
}),
|
|
174
|
+
...overrides
|
|
175
|
+
})
|
|
176
|
+
};
|