@onlineapps/conn-orch-orchestrator 1.0.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,378 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Component tests for Orchestrator
5
+ * Tests orchestrator with real internal components but mocked external services
6
+ */
7
+
8
+ const WorkflowOrchestrator = require('../../src/WorkflowOrchestrator');
9
+
10
+ describe('Orchestrator - Component Tests', () => {
11
+ let orchestrator;
12
+ let mqClientStub;
13
+ let registryClientStub;
14
+ let apiMapperStub;
15
+
16
+ beforeEach(() => {
17
+ // Create stubbed versions of external dependencies
18
+ // that simulate real behavior
19
+ mqClientStub = {
20
+ connected: true,
21
+ queues: new Map(),
22
+
23
+ publish: jest.fn(async (message, options) => {
24
+ // Simulate message publishing
25
+ const queueName = options.queue || 'default';
26
+ if (!mqClientStub.queues.has(queueName)) {
27
+ mqClientStub.queues.set(queueName, []);
28
+ }
29
+ mqClientStub.queues.get(queueName).push(message);
30
+ return { published: true };
31
+ }),
32
+
33
+ consume: jest.fn(async (handler, options) => {
34
+ // Store handler for later invocation
35
+ mqClientStub.handler = handler;
36
+ return { consuming: true };
37
+ }),
38
+
39
+ isConnected: () => mqClientStub.connected
40
+ };
41
+
42
+ registryClientStub = {
43
+ services: new Map([
44
+ ['test-service', {
45
+ name: 'test-service',
46
+ url: 'http://localhost:3000',
47
+ openapi: {
48
+ openapi: '3.0.0',
49
+ paths: {
50
+ '/test': {
51
+ post: {
52
+ operationId: 'testOp',
53
+ requestBody: {
54
+ content: {
55
+ 'application/json': {
56
+ schema: { type: 'object' }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }],
65
+ ['invoice-service', {
66
+ name: 'invoice-service',
67
+ url: 'http://localhost:3001',
68
+ openapi: {}
69
+ }]
70
+ ]),
71
+
72
+ getService: jest.fn(async (serviceName) => {
73
+ return registryClientStub.services.get(serviceName);
74
+ }),
75
+
76
+ getAllServices: jest.fn(async () => {
77
+ return Array.from(registryClientStub.services.values());
78
+ })
79
+ };
80
+
81
+ apiMapperStub = {
82
+ operations: new Map(),
83
+
84
+ callOperation: jest.fn(async (operationId, input, context) => {
85
+ // Simulate successful API call
86
+ return {
87
+ status: 200,
88
+ data: {
89
+ operationId,
90
+ input,
91
+ result: `Processed ${operationId}`
92
+ }
93
+ };
94
+ }),
95
+
96
+ parseOpenApi: jest.fn((spec) => {
97
+ // Extract operations from OpenAPI spec
98
+ const operations = {};
99
+ if (spec.paths) {
100
+ Object.entries(spec.paths).forEach(([path, methods]) => {
101
+ Object.entries(methods).forEach(([method, op]) => {
102
+ if (op.operationId) {
103
+ operations[op.operationId] = { path, method, ...op };
104
+ }
105
+ });
106
+ });
107
+ }
108
+ return operations;
109
+ })
110
+ };
111
+
112
+ // Create orchestrator with component stubs
113
+ orchestrator = new WorkflowOrchestrator({
114
+ mqClient: mqClientStub,
115
+ registryClient: registryClientStub,
116
+ apiMapper: apiMapperStub,
117
+ cookbook: {
118
+ validateCookbook: jest.fn(),
119
+ CookbookExecutor: class {
120
+ execute() {
121
+ return Promise.resolve({ results: {} });
122
+ }
123
+ }
124
+ },
125
+ logger: {
126
+ info: jest.fn(),
127
+ error: jest.fn(),
128
+ warn: jest.fn(),
129
+ debug: jest.fn()
130
+ }
131
+ });
132
+ });
133
+
134
+ describe('Workflow Processing', () => {
135
+ it('should process a complete workflow with multiple steps', async () => {
136
+ const workflow = {
137
+ workflow_id: 'wf-test-123',
138
+ current_step: {
139
+ id: 'step1',
140
+ service: 'test-service',
141
+ operation: 'testOp',
142
+ input: { value: 100 },
143
+ output: 'step1Result'
144
+ },
145
+ next_steps: [
146
+ {
147
+ id: 'step2',
148
+ service: 'invoice-service',
149
+ operation: 'createInvoice',
150
+ input: {
151
+ amount: '{{step1Result.value}}'
152
+ }
153
+ }
154
+ ],
155
+ context: {
156
+ variables: { customerId: 'cust-123' },
157
+ results: {}
158
+ }
159
+ };
160
+
161
+ // Process first step
162
+ const result = await orchestrator.processWorkflowMessage(workflow, 'test-service');
163
+
164
+ expect(result).toBeDefined();
165
+ expect(apiMapperStub.callOperation).toHaveBeenCalledWith(
166
+ 'testOp',
167
+ { value: 100 },
168
+ expect.any(Object)
169
+ );
170
+
171
+ // Verify message was routed to next service
172
+ if (workflow.next_steps && workflow.next_steps.length > 0) {
173
+ expect(mqClientStub.publish).toHaveBeenCalledWith(
174
+ expect.objectContaining({
175
+ workflow_id: 'wf-test-123',
176
+ current_step: expect.objectContaining({
177
+ service: 'invoice-service'
178
+ })
179
+ }),
180
+ expect.objectContaining({
181
+ queue: 'invoice-service.workflow'
182
+ })
183
+ );
184
+ }
185
+ });
186
+
187
+ it('should handle parallel step execution', async () => {
188
+ const workflow = {
189
+ workflow_id: 'wf-parallel-123',
190
+ current_step: {
191
+ id: 'fork',
192
+ type: 'parallel',
193
+ steps: [
194
+ {
195
+ id: 'parallel1',
196
+ service: 'test-service',
197
+ operation: 'op1'
198
+ },
199
+ {
200
+ id: 'parallel2',
201
+ service: 'test-service',
202
+ operation: 'op2'
203
+ }
204
+ ]
205
+ },
206
+ context: {}
207
+ };
208
+
209
+ await orchestrator.processWorkflowMessage(workflow, 'test-service');
210
+
211
+ // Both operations should be called
212
+ expect(apiMapperStub.callOperation).toHaveBeenCalledTimes(2);
213
+ });
214
+
215
+ it('should handle conditional routing', async () => {
216
+ const workflow = {
217
+ workflow_id: 'wf-conditional-123',
218
+ current_step: {
219
+ id: 'decision',
220
+ service: 'test-service',
221
+ operation: 'checkCondition',
222
+ input: { threshold: 50 },
223
+ output: 'decision'
224
+ },
225
+ next_steps: [
226
+ {
227
+ id: 'highPath',
228
+ condition: '{{decision.value}} > 50',
229
+ service: 'invoice-service',
230
+ operation: 'highValue'
231
+ },
232
+ {
233
+ id: 'lowPath',
234
+ condition: '{{decision.value}} <= 50',
235
+ service: 'invoice-service',
236
+ operation: 'lowValue'
237
+ }
238
+ ],
239
+ context: {}
240
+ };
241
+
242
+ // Mock API response
243
+ apiMapperStub.callOperation.mockResolvedValueOnce({
244
+ status: 200,
245
+ data: { value: 75 }
246
+ });
247
+
248
+ await orchestrator.processWorkflowMessage(workflow, 'test-service');
249
+
250
+ // Should route to high value path
251
+ expect(mqClientStub.publish).toHaveBeenCalledWith(
252
+ expect.objectContaining({
253
+ current_step: expect.objectContaining({
254
+ operation: 'highValue'
255
+ })
256
+ }),
257
+ expect.any(Object)
258
+ );
259
+ });
260
+ });
261
+
262
+ describe('Error Handling', () => {
263
+ it('should handle service unavailable gracefully', async () => {
264
+ registryClientStub.getService.mockResolvedValueOnce(null);
265
+
266
+ const workflow = {
267
+ workflow_id: 'wf-error-123',
268
+ current_step: {
269
+ service: 'unknown-service',
270
+ operation: 'test'
271
+ }
272
+ };
273
+
274
+ await expect(
275
+ orchestrator.processWorkflowMessage(workflow, 'unknown-service')
276
+ ).rejects.toThrow();
277
+ });
278
+
279
+ it('should handle API call failures', async () => {
280
+ apiMapperStub.callOperation.mockRejectedValueOnce(
281
+ new Error('Connection refused')
282
+ );
283
+
284
+ const workflow = {
285
+ workflow_id: 'wf-api-error-123',
286
+ current_step: {
287
+ service: 'test-service',
288
+ operation: 'failingOp'
289
+ }
290
+ };
291
+
292
+ await expect(
293
+ orchestrator.processWorkflowMessage(workflow, 'test-service')
294
+ ).rejects.toThrow('Connection refused');
295
+ });
296
+
297
+ it('should handle malformed workflows', async () => {
298
+ const malformedWorkflow = {
299
+ // Missing required fields
300
+ workflow_id: 'wf-malformed-123'
301
+ };
302
+
303
+ await expect(
304
+ orchestrator.processWorkflowMessage(malformedWorkflow, 'test-service')
305
+ ).rejects.toThrow();
306
+ });
307
+ });
308
+
309
+ describe('Context Management', () => {
310
+ it('should maintain context across steps', async () => {
311
+ const workflow = {
312
+ workflow_id: 'wf-context-123',
313
+ current_step: {
314
+ id: 'step1',
315
+ service: 'test-service',
316
+ operation: 'addContext',
317
+ input: { newData: 'test' },
318
+ output: 'contextData'
319
+ },
320
+ context: {
321
+ variables: { existing: 'data' },
322
+ results: {}
323
+ }
324
+ };
325
+
326
+ await orchestrator.processWorkflowMessage(workflow, 'test-service');
327
+
328
+ // Context should be preserved in routed message
329
+ expect(mqClientStub.publish).toHaveBeenCalledWith(
330
+ expect.objectContaining({
331
+ context: expect.objectContaining({
332
+ variables: expect.objectContaining({
333
+ existing: 'data'
334
+ })
335
+ })
336
+ }),
337
+ expect.any(Object)
338
+ );
339
+ });
340
+
341
+ it('should accumulate results in context', async () => {
342
+ const workflow = {
343
+ workflow_id: 'wf-results-123',
344
+ current_step: {
345
+ id: 'step2',
346
+ service: 'test-service',
347
+ operation: 'process',
348
+ output: 'step2Result'
349
+ },
350
+ context: {
351
+ results: {
352
+ step1Result: { previous: 'data' }
353
+ }
354
+ }
355
+ };
356
+
357
+ apiMapperStub.callOperation.mockResolvedValueOnce({
358
+ status: 200,
359
+ data: { new: 'result' }
360
+ });
361
+
362
+ await orchestrator.processWorkflowMessage(workflow, 'test-service');
363
+
364
+ // Results should accumulate
365
+ expect(mqClientStub.publish).toHaveBeenCalledWith(
366
+ expect.objectContaining({
367
+ context: expect.objectContaining({
368
+ results: expect.objectContaining({
369
+ step1Result: { previous: 'data' },
370
+ step2Result: { new: 'result' }
371
+ })
372
+ })
373
+ }),
374
+ expect.any(Object)
375
+ );
376
+ });
377
+ });
378
+ });
@@ -0,0 +1,313 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Integration tests for Orchestrator
5
+ * Tests orchestrator with real external services (when available)
6
+ */
7
+
8
+ const WorkflowOrchestrator = require('../../src');
9
+ const MQConnector = require('@onlineapps/conn-infra-mq');
10
+ const RegistryConnector = require('@onlineapps/conn-orch-registry');
11
+ const ApiMapperConnector = require('@onlineapps/conn-orch-api-mapper');
12
+ const CookbookConnector = require('@onlineapps/conn-orch-cookbook');
13
+
14
+ // Skip integration tests if external services are not available
15
+ const SKIP_INTEGRATION = process.env.SKIP_INTEGRATION === 'true';
16
+ const RABBITMQ_URL = process.env.RABBITMQ_URL || 'amqp://localhost:5672';
17
+ const REGISTRY_URL = process.env.REGISTRY_URL || 'http://localhost:4000';
18
+
19
+ describe('Orchestrator - Integration Tests', () => {
20
+ if (SKIP_INTEGRATION) {
21
+ it.skip('Skipping integration tests (SKIP_INTEGRATION=true)', () => {});
22
+ return;
23
+ }
24
+
25
+ let orchestrator;
26
+ let mqClient;
27
+ let registryClient;
28
+
29
+ beforeAll(async () => {
30
+ // Setup real connections
31
+ try {
32
+ mqClient = new MQConnector({
33
+ url: RABBITMQ_URL,
34
+ serviceName: 'test-orchestrator'
35
+ });
36
+ await mqClient.connect();
37
+
38
+ registryClient = new RegistryConnector.ServiceRegistryClient({
39
+ registryUrl: REGISTRY_URL
40
+ });
41
+
42
+ // Register test service
43
+ await registryClient.register({
44
+ name: 'test-orchestrator',
45
+ url: 'http://localhost:9999',
46
+ openapi: {
47
+ openapi: '3.0.0',
48
+ info: { title: 'Test Service', version: '1.0.0' },
49
+ paths: {
50
+ '/echo': {
51
+ post: {
52
+ operationId: 'echo',
53
+ requestBody: {
54
+ content: {
55
+ 'application/json': {
56
+ schema: { type: 'object' }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ });
65
+ } catch (error) {
66
+ console.warn('Failed to setup integration test environment:', error.message);
67
+ console.warn('Skipping integration tests');
68
+ return;
69
+ }
70
+ });
71
+
72
+ afterAll(async () => {
73
+ // Cleanup
74
+ if (orchestrator) {
75
+ await orchestrator.shutdown();
76
+ }
77
+ if (registryClient) {
78
+ await registryClient.unregister('test-orchestrator');
79
+ }
80
+ if (mqClient) {
81
+ await mqClient.disconnect();
82
+ }
83
+ });
84
+
85
+ beforeEach(() => {
86
+ const apiMapper = ApiMapperConnector.create({
87
+ openApiSpec: {
88
+ openapi: '3.0.0',
89
+ paths: {
90
+ '/echo': {
91
+ post: {
92
+ operationId: 'echo'
93
+ }
94
+ }
95
+ }
96
+ },
97
+ serviceUrl: 'http://localhost:9999'
98
+ });
99
+
100
+ orchestrator = WorkflowOrchestrator.create({
101
+ mqClient,
102
+ registryClient,
103
+ apiMapper,
104
+ cookbook: CookbookConnector,
105
+ logger: {
106
+ info: jest.fn(),
107
+ error: jest.fn(),
108
+ warn: jest.fn(),
109
+ debug: jest.fn()
110
+ }
111
+ });
112
+ });
113
+
114
+ describe('End-to-End Workflow Execution', () => {
115
+ it('should execute a simple workflow end-to-end', async () => {
116
+ const workflow = {
117
+ workflow_id: 'int-test-' + Date.now(),
118
+ current_step: {
119
+ id: 'step1',
120
+ service: 'test-orchestrator',
121
+ operation: 'echo',
122
+ input: { message: 'Hello Integration Test' },
123
+ output: 'echoResult'
124
+ },
125
+ context: {
126
+ variables: {},
127
+ results: {}
128
+ }
129
+ };
130
+
131
+ // Mock the actual HTTP call since we don't have a real service
132
+ orchestrator.apiMapper.callOperation = jest.fn().mockResolvedValue({
133
+ status: 200,
134
+ data: { echoed: 'Hello Integration Test' }
135
+ });
136
+
137
+ const result = await orchestrator.processWorkflowMessage(
138
+ workflow,
139
+ 'test-orchestrator'
140
+ );
141
+
142
+ expect(result).toBeDefined();
143
+ expect(result.data).toHaveProperty('echoed');
144
+ });
145
+
146
+ it('should handle message routing through RabbitMQ', async () => {
147
+ const testQueue = 'test.integration.queue';
148
+
149
+ // Setup consumer
150
+ let receivedMessage = null;
151
+ await mqClient.consume(
152
+ (message) => {
153
+ receivedMessage = message;
154
+ return Promise.resolve();
155
+ },
156
+ { queue: testQueue }
157
+ );
158
+
159
+ // Send message through orchestrator
160
+ const workflow = {
161
+ workflow_id: 'route-test-' + Date.now(),
162
+ current_step: {
163
+ id: 'route',
164
+ service: 'test-orchestrator',
165
+ operation: 'echo',
166
+ input: { test: 'data' }
167
+ },
168
+ next_steps: [
169
+ {
170
+ id: 'next',
171
+ service: 'next-service',
172
+ operation: 'process'
173
+ }
174
+ ],
175
+ context: {}
176
+ };
177
+
178
+ // Mock the API call
179
+ orchestrator.apiMapper.callOperation = jest.fn().mockResolvedValue({
180
+ status: 200,
181
+ data: { processed: true }
182
+ });
183
+
184
+ // Override routing to use test queue
185
+ orchestrator.routeToNextStep = async (workflowId, step, context) => {
186
+ await mqClient.publish(
187
+ { workflow_id: workflowId, current_step: step, context },
188
+ { queue: testQueue }
189
+ );
190
+ };
191
+
192
+ await orchestrator.processWorkflowMessage(workflow, 'test-orchestrator');
193
+
194
+ // Wait for message to be received
195
+ await new Promise(resolve => setTimeout(resolve, 100));
196
+
197
+ expect(receivedMessage).toBeTruthy();
198
+ expect(receivedMessage.workflow_id).toBe(workflow.workflow_id);
199
+ });
200
+
201
+ it('should interact with registry for service discovery', async () => {
202
+ // Get service info from registry
203
+ const serviceInfo = await registryClient.getService('test-orchestrator');
204
+
205
+ expect(serviceInfo).toBeDefined();
206
+ expect(serviceInfo.name).toBe('test-orchestrator');
207
+ expect(serviceInfo.openapi).toBeDefined();
208
+ });
209
+ });
210
+
211
+ describe('Fault Tolerance', () => {
212
+ it('should handle temporary network failures', async () => {
213
+ // Simulate network failure
214
+ orchestrator.apiMapper.callOperation = jest.fn()
215
+ .mockRejectedValueOnce(new Error('ECONNREFUSED'))
216
+ .mockResolvedValueOnce({ status: 200, data: { ok: true } });
217
+
218
+ const workflow = {
219
+ workflow_id: 'retry-test-' + Date.now(),
220
+ current_step: {
221
+ service: 'test-orchestrator',
222
+ operation: 'echo',
223
+ retry: { max: 3, delay: 100 }
224
+ }
225
+ };
226
+
227
+ // With retry logic (if implemented)
228
+ const result = await orchestrator.processWorkflowMessage(
229
+ workflow,
230
+ 'test-orchestrator'
231
+ ).catch(e => e);
232
+
233
+ // Should eventually succeed or fail after retries
234
+ expect(orchestrator.apiMapper.callOperation).toHaveBeenCalled();
235
+ });
236
+
237
+ it('should handle message acknowledgment correctly', async () => {
238
+ const workflow = {
239
+ workflow_id: 'ack-test-' + Date.now(),
240
+ current_step: {
241
+ service: 'test-orchestrator',
242
+ operation: 'echo'
243
+ }
244
+ };
245
+
246
+ orchestrator.apiMapper.callOperation = jest.fn()
247
+ .mockResolvedValue({ status: 200, data: {} });
248
+
249
+ await orchestrator.processWorkflowMessage(workflow, 'test-orchestrator');
250
+
251
+ // Message should be processed without errors
252
+ expect(orchestrator.apiMapper.callOperation).toHaveBeenCalled();
253
+ });
254
+ });
255
+
256
+ describe('Performance', () => {
257
+ it('should handle multiple concurrent workflows', async () => {
258
+ const workflows = Array.from({ length: 10 }, (_, i) => ({
259
+ workflow_id: `perf-test-${Date.now()}-${i}`,
260
+ current_step: {
261
+ service: 'test-orchestrator',
262
+ operation: 'echo',
263
+ input: { index: i }
264
+ }
265
+ }));
266
+
267
+ orchestrator.apiMapper.callOperation = jest.fn()
268
+ .mockResolvedValue({ status: 200, data: {} });
269
+
270
+ const startTime = Date.now();
271
+
272
+ await Promise.all(
273
+ workflows.map(w =>
274
+ orchestrator.processWorkflowMessage(w, 'test-orchestrator')
275
+ )
276
+ );
277
+
278
+ const duration = Date.now() - startTime;
279
+
280
+ expect(orchestrator.apiMapper.callOperation).toHaveBeenCalledTimes(10);
281
+ expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
282
+ });
283
+
284
+ it('should not leak memory during long-running operations', async () => {
285
+ const initialMemory = process.memoryUsage().heapUsed;
286
+
287
+ // Process many workflows
288
+ for (let i = 0; i < 100; i++) {
289
+ const workflow = {
290
+ workflow_id: `mem-test-${i}`,
291
+ current_step: {
292
+ service: 'test-orchestrator',
293
+ operation: 'echo',
294
+ input: { data: 'x'.repeat(1000) } // 1KB of data
295
+ }
296
+ };
297
+
298
+ orchestrator.apiMapper.callOperation = jest.fn()
299
+ .mockResolvedValue({ status: 200, data: {} });
300
+
301
+ await orchestrator.processWorkflowMessage(workflow, 'test-orchestrator');
302
+ }
303
+
304
+ global.gc && global.gc(); // Force garbage collection if available
305
+
306
+ const finalMemory = process.memoryUsage().heapUsed;
307
+ const memoryGrowth = finalMemory - initialMemory;
308
+
309
+ // Memory growth should be reasonable (< 50MB)
310
+ expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
311
+ });
312
+ });
313
+ });