@friggframework/core 2.0.0--canary.395.6ee402d.0 → 2.0.0--canary.395.704a466.0

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.
@@ -0,0 +1,427 @@
1
+ //todo: this test can probably be removed later
2
+
3
+ /**
4
+ * Integration test to verify that the auth flow works without requiring a database record
5
+ * This test simulates the actual problem we're solving.
6
+ */
7
+
8
+ const {
9
+ IntegrationEventDispatcher,
10
+ } = require('./integration-event-dispatcher');
11
+ const { IntegrationBase } = require('../integrations/integration-base');
12
+
13
+ // Simulate AsanaIntegration-like class
14
+ class SimulatedAsanaIntegration extends IntegrationBase {
15
+ static Definition = {
16
+ name: 'asana',
17
+ version: '1.0.0',
18
+ modules: {
19
+ asana: { definition: { name: 'asana' } },
20
+ frontify: { definition: { name: 'frontify' } },
21
+ },
22
+ routes: [
23
+ { path: '/auth', method: 'GET', event: 'AUTH_REQUEST' },
24
+ {
25
+ path: '/auth/redirect/:provider',
26
+ method: 'GET',
27
+ event: 'AUTH_REDIRECT',
28
+ },
29
+ { path: '/fetch-form', method: 'GET', event: 'LOAD_FORM' },
30
+ ],
31
+ };
32
+
33
+ constructor(params) {
34
+ super(params);
35
+
36
+ // Define events like AsanaIntegration does
37
+ this.events = {
38
+ AUTH_REQUEST: {
39
+ handler: this.authRequest.bind(this),
40
+ },
41
+ AUTH_REDIRECT: {
42
+ handler: this.authRedirect.bind(this),
43
+ },
44
+ LOAD_FORM: {
45
+ handler: this.loadForm.bind(this),
46
+ },
47
+ };
48
+ }
49
+
50
+ async authRequest({ req, res }) {
51
+ // This should work WITHOUT any database record
52
+ // Simulating OAuth flow initiation
53
+ const provider = req.params?.provider || 'asana';
54
+
55
+ return {
56
+ success: true,
57
+ action: 'redirect',
58
+ url: `https://app.asana.com/oauth/authorize?client_id=test&redirect_uri=test`,
59
+ provider: provider,
60
+ message: 'Auth initiated without database record!',
61
+ };
62
+ }
63
+
64
+ async authRedirect({ req, res }) {
65
+ // Handle OAuth callback
66
+ const code = req.query?.code;
67
+
68
+ if (!code) {
69
+ throw new Error('No authorization code provided');
70
+ }
71
+
72
+ // In real implementation, would exchange code for tokens
73
+ // and create database record here
74
+ return {
75
+ success: true,
76
+ action: 'tokens_received',
77
+ message: 'Would create integration record here',
78
+ };
79
+ }
80
+
81
+ async loadForm({ req, res }) {
82
+ // This SHOULD require database record
83
+ if (!this.id) {
84
+ throw new Error('Integration not found - must authenticate first');
85
+ }
86
+
87
+ return {
88
+ success: true,
89
+ form: {
90
+ fields: ['field1', 'field2'],
91
+ },
92
+ integrationId: this.id,
93
+ };
94
+ }
95
+ }
96
+
97
+ describe('Auth Flow Integration Test', () => {
98
+ let dispatcher;
99
+ let mockIntegrationRepository;
100
+ let mockModuleRepository;
101
+ let mockModuleFactory;
102
+
103
+ beforeEach(() => {
104
+ // Create mocks
105
+ mockIntegrationRepository = {
106
+ findIntegrationByName: jest.fn(),
107
+ };
108
+
109
+ mockModuleRepository = {
110
+ findEntitiesByIds: jest.fn(),
111
+ };
112
+
113
+ mockModuleFactory = {
114
+ getModuleInstance: jest.fn(),
115
+ };
116
+
117
+ // Create dispatcher
118
+ dispatcher = new IntegrationEventDispatcher({
119
+ integrationRepository: mockIntegrationRepository,
120
+ moduleFactory: mockModuleFactory,
121
+ moduleRepository: mockModuleRepository,
122
+ });
123
+ });
124
+
125
+ describe('First-time authentication (no database record)', () => {
126
+ it('should handle AUTH_REQUEST without database record', async () => {
127
+ // Simulate: No integration exists in database yet
128
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(
129
+ null
130
+ );
131
+
132
+ const req = { params: { provider: 'asana' }, query: {} };
133
+ const res = { json: jest.fn(), redirect: jest.fn() };
134
+ const next = jest.fn();
135
+
136
+ // This is the key test - auth should work WITHOUT database record
137
+ const result = await dispatcher.dispatchHttp({
138
+ integrationClass: SimulatedAsanaIntegration,
139
+ event: 'AUTH_REQUEST',
140
+ req,
141
+ res,
142
+ next,
143
+ });
144
+
145
+ expect(result).toMatchObject({
146
+ success: true,
147
+ action: 'redirect',
148
+ message: 'Auth initiated without database record!',
149
+ });
150
+
151
+ // Verify we tried to look up the integration
152
+ expect(
153
+ mockIntegrationRepository.findIntegrationByName
154
+ ).toHaveBeenCalledWith('asana');
155
+
156
+ // Verify we didn't try to load modules (stateless)
157
+ expect(mockModuleFactory.getModuleInstance).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('should handle AUTH_REDIRECT without database record', async () => {
161
+ // Still no database record during OAuth callback
162
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(
163
+ null
164
+ );
165
+
166
+ const req = {
167
+ params: { provider: 'asana' },
168
+ query: { code: 'auth_code_123' },
169
+ };
170
+ const res = { json: jest.fn() };
171
+ const next = jest.fn();
172
+
173
+ const result = await dispatcher.dispatchHttp({
174
+ integrationClass: SimulatedAsanaIntegration,
175
+ event: 'AUTH_REDIRECT',
176
+ req,
177
+ res,
178
+ next,
179
+ });
180
+
181
+ expect(result).toMatchObject({
182
+ success: true,
183
+ action: 'tokens_received',
184
+ message: 'Would create integration record here',
185
+ });
186
+ });
187
+
188
+ it('should fail LOAD_FORM without database record', async () => {
189
+ // No database record means not authenticated
190
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(
191
+ null
192
+ );
193
+
194
+ const req = { query: {} };
195
+ const res = { json: jest.fn() };
196
+ const next = jest.fn();
197
+
198
+ // This should fail because loadForm requires database state
199
+ await expect(
200
+ dispatcher.dispatchHttp({
201
+ integrationClass: SimulatedAsanaIntegration,
202
+ event: 'LOAD_FORM',
203
+ req,
204
+ res,
205
+ next,
206
+ })
207
+ ).rejects.toThrow(
208
+ 'Integration not found - must authenticate first'
209
+ );
210
+ });
211
+ });
212
+
213
+ describe('Authenticated user (with database record)', () => {
214
+ it('should handle LOAD_FORM with database record', async () => {
215
+ // Simulate existing integration in database
216
+ const mockRecord = {
217
+ id: 'integration-abc-123',
218
+ userId: 'user-456',
219
+ config: {
220
+ type: 'asana',
221
+ asanaWorkspaceId: 'workspace-789',
222
+ },
223
+ status: 'ENABLED',
224
+ version: '1.0.0',
225
+ messages: { errors: [], warnings: [] },
226
+ entitiesIds: ['entity-asana', 'entity-frontify'],
227
+ };
228
+
229
+ const mockEntities = [
230
+ { _id: 'entity-asana', name: 'asana' },
231
+ { _id: 'entity-frontify', name: 'frontify' },
232
+ ];
233
+
234
+ const mockAsanaModule = {
235
+ getName: () => 'asana',
236
+ api: { getUser: jest.fn() },
237
+ };
238
+
239
+ const mockFrontifyModule = {
240
+ getName: () => 'frontify',
241
+ api: { getProjects: jest.fn() },
242
+ };
243
+
244
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(
245
+ mockRecord
246
+ );
247
+ mockModuleRepository.findEntitiesByIds.mockResolvedValue(
248
+ mockEntities
249
+ );
250
+ mockModuleFactory.getModuleInstance
251
+ .mockResolvedValueOnce(mockAsanaModule)
252
+ .mockResolvedValueOnce(mockFrontifyModule);
253
+
254
+ const req = { query: {} };
255
+ const res = { json: jest.fn() };
256
+ const next = jest.fn();
257
+
258
+ const result = await dispatcher.dispatchHttp({
259
+ integrationClass: SimulatedAsanaIntegration,
260
+ event: 'LOAD_FORM',
261
+ req,
262
+ res,
263
+ next,
264
+ });
265
+
266
+ expect(result).toMatchObject({
267
+ success: true,
268
+ form: {
269
+ fields: expect.any(Array),
270
+ },
271
+ integrationId: 'integration-abc-123',
272
+ });
273
+
274
+ // Verify modules were loaded
275
+ expect(mockModuleFactory.getModuleInstance).toHaveBeenCalledTimes(
276
+ 2
277
+ );
278
+ });
279
+ });
280
+
281
+ describe('Error scenarios', () => {
282
+ it('should gracefully handle database connection errors during auth', async () => {
283
+ // Database is down but auth should still work
284
+ mockIntegrationRepository.findIntegrationByName.mockRejectedValue(
285
+ new Error('MongoDB connection failed')
286
+ );
287
+
288
+ const req = { params: { provider: 'asana' }, query: {} };
289
+ const res = { json: jest.fn() };
290
+ const next = jest.fn();
291
+
292
+ // Should still work with stateless instance
293
+ const result = await dispatcher.dispatchHttp({
294
+ integrationClass: SimulatedAsanaIntegration,
295
+ event: 'AUTH_REQUEST',
296
+ req,
297
+ res,
298
+ next,
299
+ });
300
+
301
+ expect(result).toMatchObject({
302
+ success: true,
303
+ action: 'redirect',
304
+ message: 'Auth initiated without database record!',
305
+ });
306
+ });
307
+
308
+ it('should handle missing event gracefully', async () => {
309
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(
310
+ null
311
+ );
312
+
313
+ const req = {};
314
+ const res = { json: jest.fn() };
315
+ const next = jest.fn();
316
+
317
+ await expect(
318
+ dispatcher.dispatchHttp({
319
+ integrationClass: SimulatedAsanaIntegration,
320
+ event: 'UNKNOWN_EVENT',
321
+ req,
322
+ res,
323
+ next,
324
+ })
325
+ ).rejects.toThrow('Event UNKNOWN_EVENT not registered for asana');
326
+ });
327
+ });
328
+ });
329
+
330
+ describe('Real-world scenario simulation', () => {
331
+ it('should handle complete auth flow from start to finish', async () => {
332
+ const mockIntegrationRepository = {
333
+ findIntegrationByName: jest.fn(),
334
+ createIntegration: jest.fn(),
335
+ };
336
+
337
+ const mockModuleRepository = {
338
+ findEntitiesByIds: jest.fn(),
339
+ };
340
+
341
+ const mockModuleFactory = {
342
+ getModuleInstance: jest.fn(),
343
+ };
344
+
345
+ const dispatcher = new IntegrationEventDispatcher({
346
+ integrationRepository: mockIntegrationRepository,
347
+ moduleFactory: mockModuleFactory,
348
+ moduleRepository: mockModuleRepository,
349
+ });
350
+
351
+ // Step 1: User initiates auth - no DB record exists
352
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(null);
353
+
354
+ const authReq = { params: { provider: 'asana' } };
355
+ const authRes = { json: jest.fn() };
356
+ const authNext = jest.fn();
357
+
358
+ const step1 = await dispatcher.dispatchHttp({
359
+ integrationClass: SimulatedAsanaIntegration,
360
+ event: 'AUTH_REQUEST',
361
+ req: authReq,
362
+ res: authRes,
363
+ next: authNext,
364
+ });
365
+
366
+ expect(step1.success).toBe(true);
367
+ expect(step1.action).toBe('redirect');
368
+
369
+ // Step 2: OAuth callback - still no DB record
370
+ const callbackReq = {
371
+ params: { provider: 'asana' },
372
+ query: { code: 'oauth_code_xyz' },
373
+ };
374
+ const callbackRes = { json: jest.fn() };
375
+ const callbackNext = jest.fn();
376
+
377
+ const step2 = await dispatcher.dispatchHttp({
378
+ integrationClass: SimulatedAsanaIntegration,
379
+ event: 'AUTH_REDIRECT',
380
+ req: callbackReq,
381
+ res: callbackRes,
382
+ next: callbackNext,
383
+ });
384
+
385
+ expect(step2.success).toBe(true);
386
+ expect(step2.message).toBe('Would create integration record here');
387
+
388
+ // In real implementation, the AUTH_REDIRECT handler would create the DB record
389
+ // Let's simulate that it was created
390
+
391
+ // Step 3: Now user can access protected routes with DB record
392
+ const mockRecord = {
393
+ id: 'new-integration-123',
394
+ userId: 'user-456',
395
+ config: { type: 'asana' },
396
+ status: 'ENABLED',
397
+ version: '1.0.0',
398
+ messages: { errors: [], warnings: [] },
399
+ entitiesIds: [],
400
+ };
401
+
402
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(
403
+ mockRecord
404
+ );
405
+ mockModuleRepository.findEntitiesByIds.mockResolvedValue([]);
406
+
407
+ const formReq = { query: {} };
408
+ const formRes = { json: jest.fn() };
409
+ const formNext = jest.fn();
410
+
411
+ const step3 = await dispatcher.dispatchHttp({
412
+ integrationClass: SimulatedAsanaIntegration,
413
+ event: 'LOAD_FORM',
414
+ req: formReq,
415
+ res: formRes,
416
+ next: formNext,
417
+ });
418
+
419
+ expect(step3.success).toBe(true);
420
+ expect(step3.integrationId).toBe('new-integration-123');
421
+
422
+ // Verify the progression from stateless to stateful
423
+ expect(
424
+ mockIntegrationRepository.findIntegrationByName
425
+ ).toHaveBeenCalledTimes(3);
426
+ });
427
+ });
@@ -4,7 +4,7 @@ const { IntegrationRepository } = require('../integrations/integration-repositor
4
4
  const { ModuleFactory } = require('../modules/module-factory');
5
5
  const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
6
6
  const { ModuleRepository } = require('../modules/module-repository');
7
- const { GetIntegrationInstanceByDefinition } = require('../integrations/use-cases/get-integration-instance-by-definition');
7
+ const { IntegrationEventDispatcher } = require('./integration-event-dispatcher');
8
8
 
9
9
  const loadRouterFromObject = (IntegrationClass, routerObject) => {
10
10
 
@@ -14,6 +14,14 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
14
14
  moduleRepository,
15
15
  moduleDefinitions: getModulesDefinitionFromIntegrationClasses([IntegrationClass]),
16
16
  });
17
+
18
+ // Create the event dispatcher
19
+ const dispatcher = new IntegrationEventDispatcher({
20
+ integrationRepository,
21
+ moduleFactory,
22
+ moduleRepository,
23
+ });
24
+
17
25
  const router = Router();
18
26
  const { path, method, event } = routerObject;
19
27
 
@@ -23,13 +31,13 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
23
31
 
24
32
  router[method.toLowerCase()](path, async (req, res, next) => {
25
33
  try {
26
- const getIntegrationInstanceByDefinition = new GetIntegrationInstanceByDefinition({
27
- integrationRepository,
28
- moduleFactory,
29
- moduleRepository,
34
+ const result = await dispatcher.dispatchHttp({
35
+ integrationClass: IntegrationClass,
36
+ event,
37
+ req,
38
+ res,
39
+ next
30
40
  });
31
- const integration = await getIntegrationInstanceByDefinition.execute(IntegrationClass);
32
- const result = await integration.send(event, { req, res, next });
33
41
  res.json(result);
34
42
  } catch (error) {
35
43
  next(error);
@@ -50,19 +58,19 @@ const createQueueWorker = (integrationClass) => {
50
58
  moduleDefinitions: getModulesDefinitionFromIntegrationClasses([integrationClass]),
51
59
  });
52
60
 
61
+ dispatcher = new IntegrationEventDispatcher({
62
+ integrationRepository: this.integrationRepository,
63
+ moduleFactory: this.moduleFactory,
64
+ moduleRepository: this.moduleRepository,
65
+ });
66
+
53
67
  async _run(params, context) {
54
68
  try {
55
- const getIntegrationInstanceByDefinition = new GetIntegrationInstanceByDefinition({
56
- integrationRepository: this.integrationRepository,
57
- moduleFactory: this.moduleFactory,
58
- moduleRepository: this.moduleRepository,
59
- });
60
-
61
- const integration = await getIntegrationInstanceByDefinition.execute(integrationClass);
62
-
63
- const res = await integration.send(params.event, {
69
+ const res = await this.dispatcher.dispatchJob({
70
+ integrationClass: integrationClass,
71
+ event: params.event,
64
72
  data: params.data,
65
- context,
73
+ context: context,
66
74
  });
67
75
  return res;
68
76
  } catch (error) {
@@ -0,0 +1,101 @@
1
+
2
+ class IntegrationEventDispatcher {
3
+ constructor({ integrationRepository, moduleFactory, moduleRepository }) {
4
+ this.integrationRepository = integrationRepository;
5
+ this.moduleFactory = moduleFactory;
6
+ this.moduleRepository = moduleRepository;
7
+ }
8
+
9
+ async dispatchHttp({ integrationClass, event, req, res, next }) {
10
+ // Create or get integration instance
11
+ const integration = await this.resolveIntegration(integrationClass);
12
+
13
+ // Get the handler method from the integration's events object
14
+ // The integration defines this.events = { EVENT_NAME: { handler: this.method.bind(this) } }
15
+ const handler = this.findEventHandler(integration, event);
16
+
17
+ if (!handler) {
18
+ throw new Error(`Event ${event} not registered for ${integrationClass.Definition.name}`);
19
+ }
20
+
21
+ // Call the handler directly
22
+ return await handler.call(integration, { req, res, next });
23
+ }
24
+
25
+ async dispatchJob({ integrationClass, event, data, context }) {
26
+ const integration = await this.resolveIntegration(integrationClass);
27
+
28
+ const handler = this.findEventHandler(integration, event);
29
+
30
+ if (!handler) {
31
+ throw new Error(`Event ${event} not registered for ${integrationClass.Definition.name}`);
32
+ }
33
+
34
+ return await handler.call(integration, { data, context });
35
+ }
36
+
37
+ findEventHandler(integration, event) {
38
+ // First check if integration has events object
39
+ if (integration.events && integration.events[event]) {
40
+ return integration.events[event].handler;
41
+ }
42
+
43
+ // Check default events (ON_CREATE, ON_UPDATE, etc.)
44
+ if (integration.defaultEvents && integration.defaultEvents[event]) {
45
+ return integration.defaultEvents[event].handler;
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ async resolveIntegration(integrationClass) {
52
+ let integrationInstance;
53
+
54
+ try {
55
+ // Try to get persisted integration from database
56
+ const integrationRecord = await this.integrationRepository.findIntegrationByName(
57
+ integrationClass.Definition.name
58
+ );
59
+
60
+ if (integrationRecord) {
61
+ // Load with full database state and modules
62
+ const entities = await this.moduleRepository.findEntitiesByIds(integrationRecord.entitiesIds);
63
+ const modules = [];
64
+
65
+ for (const entity of entities) {
66
+ const moduleInstance = await this.moduleFactory.getModuleInstance(
67
+ entity.id,
68
+ integrationRecord.userId
69
+ );
70
+ modules.push(moduleInstance);
71
+ }
72
+
73
+ integrationInstance = new integrationClass({
74
+ id: integrationRecord.id,
75
+ userId: integrationRecord.userId,
76
+ entities: entities,
77
+ config: integrationRecord.config,
78
+ status: integrationRecord.status,
79
+ version: integrationRecord.version,
80
+ messages: integrationRecord.messages,
81
+ modules: modules
82
+ });
83
+ } else {
84
+ // No DB record - create stateless instance for auth routes
85
+ integrationInstance = new integrationClass({});
86
+ }
87
+ } catch (error) {
88
+ // If DB lookup fails, create stateless instance
89
+ integrationInstance = new integrationClass({});
90
+ }
91
+
92
+ // Initialize the integration (loads dynamic events if any)
93
+ if (typeof integrationInstance.initialize === 'function') {
94
+ await integrationInstance.initialize();
95
+ }
96
+
97
+ return integrationInstance;
98
+ }
99
+ }
100
+
101
+ module.exports = { IntegrationEventDispatcher };
@@ -0,0 +1,473 @@
1
+ const { IntegrationEventDispatcher } = require('./integration-event-dispatcher');
2
+ const { IntegrationBase } = require('../integrations/integration-base');
3
+
4
+ // Mock dependencies
5
+ const mockIntegrationRepository = {
6
+ findIntegrationByName: jest.fn()
7
+ };
8
+
9
+ const mockModuleRepository = {
10
+ findEntitiesByIds: jest.fn()
11
+ };
12
+
13
+ const mockModuleFactory = {
14
+ getModuleInstance: jest.fn()
15
+ };
16
+
17
+ // Create a test integration class
18
+ class TestIntegration extends IntegrationBase {
19
+ static Definition = {
20
+ name: 'test-integration',
21
+ version: '1.0.0',
22
+ modules: {},
23
+ routes: [
24
+ {
25
+ path: '/auth',
26
+ method: 'GET',
27
+ event: 'AUTH_REQUEST'
28
+ },
29
+ {
30
+ path: '/data',
31
+ method: 'GET',
32
+ event: 'LOAD_DATA'
33
+ },
34
+ {
35
+ path: '/test',
36
+ method: 'POST',
37
+ event: 'TEST_EVENT'
38
+ },
39
+ {
40
+ path: '/dynamic',
41
+ method: 'GET',
42
+ event: 'DYNAMIC_EVENT'
43
+ }
44
+ ]
45
+ };
46
+
47
+ constructor(params) {
48
+ super(params);
49
+ // Define events in constructor like AsanaIntegration does
50
+ this.events = {
51
+ 'AUTH_REQUEST': {
52
+ handler: this.authRequest.bind(this)
53
+ },
54
+ 'LOAD_DATA': {
55
+ handler: this.loadData.bind(this)
56
+ },
57
+ 'TEST_EVENT': {
58
+ handler: this.testHandler.bind(this)
59
+ }
60
+ };
61
+ }
62
+
63
+ async authRequest({ req, res }) {
64
+ return {
65
+ success: true,
66
+ message: 'Auth request handled',
67
+ hasDatabase: !!this.id
68
+ };
69
+ }
70
+
71
+ async loadData({ req, res }) {
72
+ if (!this.id) {
73
+ throw new Error('Database record required');
74
+ }
75
+ return {
76
+ success: true,
77
+ data: this.config,
78
+ integrationId: this.id
79
+ };
80
+ }
81
+
82
+ async testHandler({ data }) {
83
+ return {
84
+ success: true,
85
+ received: data
86
+ };
87
+ }
88
+
89
+ async initialize() {
90
+ // Simulate loading dynamic events
91
+ const additionalEvents = {
92
+ 'DYNAMIC_EVENT': {
93
+ handler: this.dynamicHandler.bind(this)
94
+ }
95
+ };
96
+ this.events = { ...this.events, ...additionalEvents };
97
+ }
98
+
99
+ async dynamicHandler({ req }) {
100
+ return { dynamic: true };
101
+ }
102
+ }
103
+
104
+ describe('IntegrationEventDispatcher', () => {
105
+ let dispatcher;
106
+
107
+ beforeEach(() => {
108
+ // Reset all mocks
109
+ jest.clearAllMocks();
110
+
111
+ // Create dispatcher instance
112
+ dispatcher = new IntegrationEventDispatcher({
113
+ integrationRepository: mockIntegrationRepository,
114
+ moduleFactory: mockModuleFactory,
115
+ moduleRepository: mockModuleRepository
116
+ });
117
+ });
118
+
119
+ describe('dispatchHttp', () => {
120
+ it('should handle auth request without database record (stateless)', async () => {
121
+ // Mock no database record found
122
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(null);
123
+
124
+ const req = { headers: {} };
125
+ const res = { json: jest.fn() };
126
+ const next = jest.fn();
127
+
128
+ const result = await dispatcher.dispatchHttp({
129
+ integrationClass: TestIntegration,
130
+ event: 'AUTH_REQUEST',
131
+ req,
132
+ res,
133
+ next
134
+ });
135
+
136
+ expect(result).toEqual({
137
+ success: true,
138
+ message: 'Auth request handled',
139
+ hasDatabase: false
140
+ });
141
+
142
+ // Verify repository was called
143
+ expect(mockIntegrationRepository.findIntegrationByName).toHaveBeenCalledWith('test-integration');
144
+
145
+ // Verify modules were not loaded (stateless)
146
+ expect(mockModuleRepository.findEntitiesByIds).not.toHaveBeenCalled();
147
+ expect(mockModuleFactory.getModuleInstance).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it('should handle request with database record (stateful)', async () => {
151
+ // Mock database record
152
+ const mockRecord = {
153
+ id: 'integration-123',
154
+ userId: 'user-456',
155
+ config: { type: 'test', apiKey: 'secret' },
156
+ status: 'ENABLED',
157
+ version: '1.0.0',
158
+ messages: { errors: [], warnings: [] },
159
+ entitiesIds: ['entity-1', 'entity-2']
160
+ };
161
+
162
+ const mockEntities = [
163
+ { _id: 'entity-1', name: 'module1' },
164
+ { _id: 'entity-2', name: 'module2' }
165
+ ];
166
+
167
+ const mockModule1 = { getName: () => 'module1', api: {} };
168
+ const mockModule2 = { getName: () => 'module2', api: {} };
169
+
170
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(mockRecord);
171
+ mockModuleRepository.findEntitiesByIds.mockResolvedValue(mockEntities);
172
+ mockModuleFactory.getModuleInstance
173
+ .mockResolvedValueOnce(mockModule1)
174
+ .mockResolvedValueOnce(mockModule2);
175
+
176
+ const req = { headers: {} };
177
+ const res = { json: jest.fn() };
178
+ const next = jest.fn();
179
+
180
+ const result = await dispatcher.dispatchHttp({
181
+ integrationClass: TestIntegration,
182
+ event: 'LOAD_DATA',
183
+ req,
184
+ res,
185
+ next
186
+ });
187
+
188
+ expect(result).toEqual({
189
+ success: true,
190
+ data: mockRecord.config,
191
+ integrationId: 'integration-123'
192
+ });
193
+
194
+ // Verify all dependencies were called
195
+ expect(mockIntegrationRepository.findIntegrationByName).toHaveBeenCalledWith('test-integration');
196
+ expect(mockModuleRepository.findEntitiesByIds).toHaveBeenCalledWith(['entity-1', 'entity-2']);
197
+ expect(mockModuleFactory.getModuleInstance).toHaveBeenCalledTimes(2);
198
+ });
199
+
200
+ it('should call initialize and load dynamic events', async () => {
201
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(null);
202
+
203
+ const req = { headers: {} };
204
+ const res = { json: jest.fn() };
205
+ const next = jest.fn();
206
+
207
+ const result = await dispatcher.dispatchHttp({
208
+ integrationClass: TestIntegration,
209
+ event: 'DYNAMIC_EVENT',
210
+ req,
211
+ res,
212
+ next
213
+ });
214
+
215
+ expect(result).toEqual({ dynamic: true });
216
+ });
217
+
218
+ it('should throw error if event is not registered', async () => {
219
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(null);
220
+
221
+ const req = { headers: {} };
222
+ const res = { json: jest.fn() };
223
+ const next = jest.fn();
224
+
225
+ await expect(dispatcher.dispatchHttp({
226
+ integrationClass: TestIntegration,
227
+ event: 'UNKNOWN_EVENT',
228
+ req,
229
+ res,
230
+ next
231
+ })).rejects.toThrow('Event UNKNOWN_EVENT not registered for test-integration');
232
+ });
233
+
234
+ it('should handle database lookup errors gracefully', async () => {
235
+ // Mock database error
236
+ mockIntegrationRepository.findIntegrationByName.mockRejectedValue(
237
+ new Error('Database connection failed')
238
+ );
239
+
240
+ const req = { headers: {} };
241
+ const res = { json: jest.fn() };
242
+ const next = jest.fn();
243
+
244
+ // Should still work with stateless instance
245
+ const result = await dispatcher.dispatchHttp({
246
+ integrationClass: TestIntegration,
247
+ event: 'AUTH_REQUEST',
248
+ req,
249
+ res,
250
+ next
251
+ });
252
+
253
+ expect(result).toEqual({
254
+ success: true,
255
+ message: 'Auth request handled',
256
+ hasDatabase: false
257
+ });
258
+ });
259
+ });
260
+
261
+ describe('dispatchJob', () => {
262
+ it('should handle job with integration context', async () => {
263
+ const mockRecord = {
264
+ id: 'integration-123',
265
+ userId: 'user-456',
266
+ config: { type: 'test' },
267
+ status: 'ENABLED',
268
+ version: '1.0.0',
269
+ messages: { errors: [], warnings: [] },
270
+ entitiesIds: []
271
+ };
272
+
273
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(mockRecord);
274
+ mockModuleRepository.findEntitiesByIds.mockResolvedValue([]);
275
+
276
+ const data = { testData: 'value' };
277
+ const context = { integrationId: 'integration-123' };
278
+
279
+ const result = await dispatcher.dispatchJob({
280
+ integrationClass: TestIntegration,
281
+ event: 'TEST_EVENT',
282
+ data,
283
+ context
284
+ });
285
+
286
+ expect(result).toEqual({
287
+ success: true,
288
+ received: data
289
+ });
290
+ });
291
+
292
+ it('should handle job without integration context', async () => {
293
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(null);
294
+
295
+ const data = { testData: 'value' };
296
+ const context = {};
297
+
298
+ const result = await dispatcher.dispatchJob({
299
+ integrationClass: TestIntegration,
300
+ event: 'TEST_EVENT',
301
+ data,
302
+ context
303
+ });
304
+
305
+ expect(result).toEqual({
306
+ success: true,
307
+ received: data
308
+ });
309
+ });
310
+
311
+ it('should throw error for unknown job event', async () => {
312
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(null);
313
+
314
+ const data = {};
315
+ const context = {};
316
+
317
+ await expect(dispatcher.dispatchJob({
318
+ integrationClass: TestIntegration,
319
+ event: 'UNKNOWN_JOB',
320
+ data,
321
+ context
322
+ })).rejects.toThrow('Event UNKNOWN_JOB not registered for test-integration');
323
+ });
324
+ });
325
+
326
+ describe('findEventHandler', () => {
327
+ it('should find handler in events object', async () => {
328
+ const integration = new TestIntegration({});
329
+ await integration.initialize();
330
+
331
+ const handler = dispatcher.findEventHandler(integration, 'AUTH_REQUEST');
332
+ expect(handler).toBeDefined();
333
+ expect(typeof handler).toBe('function');
334
+ });
335
+
336
+ it('should find handler in defaultEvents', async () => {
337
+ const integration = new TestIntegration({});
338
+
339
+ const handler = dispatcher.findEventHandler(integration, 'ON_CREATE');
340
+ expect(handler).toBeDefined();
341
+ expect(typeof handler).toBe('function');
342
+ });
343
+
344
+ it('should return null for non-existent event', async () => {
345
+ const integration = new TestIntegration({});
346
+
347
+ const handler = dispatcher.findEventHandler(integration, 'NON_EXISTENT');
348
+ expect(handler).toBeNull();
349
+ });
350
+ });
351
+
352
+ describe('resolveIntegration', () => {
353
+ it('should create stateless instance when no database record exists', async () => {
354
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(null);
355
+
356
+ const integration = await dispatcher.resolveIntegration(TestIntegration);
357
+
358
+ expect(integration).toBeInstanceOf(TestIntegration);
359
+ expect(integration.id).toBeUndefined();
360
+ expect(integration.config).toBeUndefined();
361
+ });
362
+
363
+ it('should create stateful instance with all properties', async () => {
364
+ const mockRecord = {
365
+ id: 'integration-123',
366
+ userId: 'user-456',
367
+ config: { apiKey: 'secret' },
368
+ status: 'ENABLED',
369
+ version: '1.0.0',
370
+ messages: { errors: [], warnings: [] },
371
+ entitiesIds: ['entity-1']
372
+ };
373
+
374
+ const mockEntities = [{ _id: 'entity-1', name: 'module1' }];
375
+ const mockModule = {
376
+ getName: () => 'testModule',
377
+ name: 'testModule',
378
+ api: {}
379
+ };
380
+
381
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(mockRecord);
382
+ mockModuleRepository.findEntitiesByIds.mockResolvedValue(mockEntities);
383
+ mockModuleFactory.getModuleInstance.mockResolvedValue(mockModule);
384
+
385
+ const integration = await dispatcher.resolveIntegration(TestIntegration);
386
+
387
+ expect(integration).toBeInstanceOf(TestIntegration);
388
+ expect(integration.id).toBe('integration-123');
389
+ expect(integration.userId).toBe('user-456');
390
+ expect(integration.config).toEqual({ apiKey: 'secret' });
391
+ expect(integration.status).toBe('ENABLED');
392
+ expect(integration.testModule).toBe(mockModule);
393
+ });
394
+
395
+ it('should fall back to stateless when module loading fails', async () => {
396
+ const mockRecord = {
397
+ id: 'integration-123',
398
+ userId: 'user-456',
399
+ config: {},
400
+ status: 'ENABLED',
401
+ version: '1.0.0',
402
+ messages: { errors: [], warnings: [] },
403
+ entitiesIds: ['entity-1']
404
+ };
405
+
406
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(mockRecord);
407
+ mockModuleRepository.findEntitiesByIds.mockResolvedValue([{ _id: 'entity-1' }]);
408
+ mockModuleFactory.getModuleInstance.mockRejectedValue(new Error('Module load failed'));
409
+
410
+ // Should fall back to stateless instance
411
+ const integration = await dispatcher.resolveIntegration(TestIntegration);
412
+
413
+ expect(integration).toBeInstanceOf(TestIntegration);
414
+ // Should be stateless (no id, config, etc.)
415
+ expect(integration.id).toBeUndefined();
416
+ expect(integration.config).toBeUndefined();
417
+ });
418
+ });
419
+ });
420
+
421
+ // Test for default events from IntegrationBase
422
+ describe('IntegrationEventDispatcher with default events', () => {
423
+ let dispatcher;
424
+
425
+ beforeEach(() => {
426
+ jest.clearAllMocks();
427
+ dispatcher = new IntegrationEventDispatcher({
428
+ integrationRepository: mockIntegrationRepository,
429
+ moduleFactory: mockModuleFactory,
430
+ moduleRepository: mockModuleRepository
431
+ });
432
+ });
433
+
434
+ it('should handle default ON_CREATE event', async () => {
435
+ const mockRecord = {
436
+ id: 'integration-123',
437
+ userId: 'user-456',
438
+ config: {},
439
+ status: 'ENABLED',
440
+ version: '1.0.0',
441
+ messages: { errors: [], warnings: [] },
442
+ entitiesIds: []
443
+ };
444
+
445
+ mockIntegrationRepository.findIntegrationByName.mockResolvedValue(mockRecord);
446
+ mockModuleRepository.findEntitiesByIds.mockResolvedValue([]);
447
+
448
+ // Mock the updateIntegrationStatus to avoid actual DB calls
449
+ const integration = new TestIntegration({
450
+ id: mockRecord.id,
451
+ userId: mockRecord.userId,
452
+ config: mockRecord.config,
453
+ status: mockRecord.status
454
+ });
455
+ integration.updateIntegrationStatus = {
456
+ execute: jest.fn().mockResolvedValue(true)
457
+ };
458
+
459
+ const req = {};
460
+ const res = {};
461
+ const next = jest.fn();
462
+
463
+ // The dispatcher should be able to find and call ON_CREATE
464
+ const handler = dispatcher.findEventHandler(integration, 'ON_CREATE');
465
+ expect(handler).toBeDefined();
466
+
467
+ // Call the handler
468
+ const result = await handler.call(integration, { integrationId: 'integration-123' });
469
+
470
+ // ON_CREATE should update status to ENABLED
471
+ expect(integration.updateIntegrationStatus.execute).toHaveBeenCalledWith('integration-123', 'ENABLED');
472
+ });
473
+ });
@@ -62,12 +62,7 @@ class IntegrationBase {
62
62
  return this.Definition.version;
63
63
  }
64
64
 
65
- registerEventHandlers() {
66
- this.on = {
67
- ...this.defaultEvents,
68
- ...this.events,
69
- };
70
- }
65
+ // REMOVED: registerEventHandlers() - Event handling is now done by IntegrationEventDispatcher
71
66
 
72
67
  constructor(params = {}) {
73
68
  // Data from database record (when instantiated by use cases)
@@ -130,14 +125,7 @@ class IntegrationBase {
130
125
  };
131
126
  }
132
127
 
133
- async send(event, object) {
134
- if (!this.on[event]) {
135
- throw new Error(
136
- `Event ${event} is not defined in the Integration event object`
137
- );
138
- }
139
- return this.on[event].handler.call(this, object);
140
- }
128
+ // REMOVED: send() - Event dispatching is now done by IntegrationEventDispatcher
141
129
 
142
130
  async validateConfig() {
143
131
  const configOptions = await this.getConfigOptions();
@@ -336,8 +324,7 @@ class IntegrationBase {
336
324
  this.addError(e);
337
325
  }
338
326
 
339
- // Register event handlers
340
- await this.registerEventHandlers();
327
+ // Event handlers are no longer registered here - handled by IntegrationEventDispatcher
341
328
  }
342
329
 
343
330
  getOptionDetails() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.395.6ee402d.0",
4
+ "version": "2.0.0--canary.395.704a466.0",
5
5
  "dependencies": {
6
6
  "@hapi/boom": "^10.0.1",
7
7
  "aws-sdk": "^2.1200.0",
@@ -22,9 +22,9 @@
22
22
  "uuid": "^9.0.1"
23
23
  },
24
24
  "devDependencies": {
25
- "@friggframework/eslint-config": "2.0.0--canary.395.6ee402d.0",
26
- "@friggframework/prettier-config": "2.0.0--canary.395.6ee402d.0",
27
- "@friggframework/test": "2.0.0--canary.395.6ee402d.0",
25
+ "@friggframework/eslint-config": "2.0.0--canary.395.704a466.0",
26
+ "@friggframework/prettier-config": "2.0.0--canary.395.704a466.0",
27
+ "@friggframework/test": "2.0.0--canary.395.704a466.0",
28
28
  "@types/lodash": "4.17.15",
29
29
  "@typescript-eslint/eslint-plugin": "^8.0.0",
30
30
  "chai": "^4.3.6",
@@ -56,5 +56,5 @@
56
56
  "publishConfig": {
57
57
  "access": "public"
58
58
  },
59
- "gitHead": "6ee402d0d7aec57c3f90972901751c23a3b734ec"
59
+ "gitHead": "704a466eb0e5b9fd4db245f95071f85842c698de"
60
60
  }