@onlineapps/conn-orch-orchestrator 1.0.63 → 1.0.64
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.
|
@@ -1,125 +1,47 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Component tests for
|
|
5
|
-
*
|
|
4
|
+
* Component tests for WorkflowOrchestrator (V2.1 unified cookbook message format)
|
|
5
|
+
*
|
|
6
|
+
* Goal: verify orchestration behavior (task execution + routing) without external MQ/registry.
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
jest.mock('@onlineapps/mq-client-core', () => ({
|
|
10
|
+
monitoring: {
|
|
11
|
+
publishToMonitoringWorkflow: jest.fn(async () => true)
|
|
12
|
+
}
|
|
13
|
+
}));
|
|
14
|
+
|
|
8
15
|
const WorkflowOrchestrator = require('../../src/WorkflowOrchestrator');
|
|
9
16
|
|
|
10
|
-
describe('
|
|
17
|
+
describe('WorkflowOrchestrator - Component Tests @component', () => {
|
|
11
18
|
let orchestrator;
|
|
12
19
|
let mqClientStub;
|
|
13
20
|
let registryClientStub;
|
|
14
21
|
let apiMapperStub;
|
|
15
22
|
|
|
16
23
|
beforeEach(() => {
|
|
17
|
-
// Create stubbed versions of external dependencies
|
|
18
|
-
// that simulate real behavior
|
|
19
24
|
mqClientStub = {
|
|
20
|
-
|
|
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
|
|
25
|
+
publish: jest.fn(async () => true)
|
|
40
26
|
};
|
|
41
27
|
|
|
42
28
|
registryClientStub = {
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
})
|
|
29
|
+
getService: jest.fn(async () => null),
|
|
30
|
+
getAllServices: jest.fn(async () => [])
|
|
79
31
|
};
|
|
80
32
|
|
|
81
33
|
apiMapperStub = {
|
|
82
|
-
|
|
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
|
-
})
|
|
34
|
+
callOperation: jest.fn(async (operationId, input) => ({ operationId, input }))
|
|
110
35
|
};
|
|
111
36
|
|
|
112
|
-
// Create orchestrator with component stubs
|
|
113
37
|
orchestrator = new WorkflowOrchestrator({
|
|
114
38
|
mqClient: mqClientStub,
|
|
115
39
|
registryClient: registryClientStub,
|
|
116
40
|
apiMapper: apiMapperStub,
|
|
117
41
|
cookbook: {
|
|
118
42
|
validateCookbook: jest.fn(),
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return Promise.resolve({ results: {} });
|
|
122
|
-
}
|
|
43
|
+
templateHelpers: {
|
|
44
|
+
normalizeString: (v) => String(v || '').trim()
|
|
123
45
|
}
|
|
124
46
|
},
|
|
125
47
|
logger: {
|
|
@@ -131,248 +53,68 @@ describe('Orchestrator - Component Tests @component', () => {
|
|
|
131
53
|
});
|
|
132
54
|
});
|
|
133
55
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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');
|
|
56
|
+
it('executes task and routes next task to next service queue', async () => {
|
|
57
|
+
const cookbook = {
|
|
58
|
+
version: '2.1.0',
|
|
59
|
+
api_input: { x: 1 },
|
|
60
|
+
steps: [
|
|
61
|
+
{ step_id: 's1', type: 'task', service: 'svc-a', operation: 'op1', input: { x: '{{api_input.x}}' } },
|
|
62
|
+
{ step_id: 's2', type: 'task', service: 'svc-b', operation: 'op2', input: { y: 2 } }
|
|
63
|
+
],
|
|
64
|
+
delivery: { handler: 'none' }
|
|
65
|
+
};
|
|
249
66
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
67
|
+
const msg = { workflow_id: 'wf-1', cookbook, current_step: 's1' };
|
|
68
|
+
await orchestrator.processWorkflowMessage(msg, 'svc-a');
|
|
69
|
+
|
|
70
|
+
expect(apiMapperStub.callOperation).toHaveBeenCalledWith(
|
|
71
|
+
'op1',
|
|
72
|
+
{ x: 1 },
|
|
73
|
+
expect.any(Object)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(mqClientStub.publish).toHaveBeenCalledWith(
|
|
77
|
+
'svc-b.workflow',
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
workflow_id: 'wf-1',
|
|
80
|
+
current_step: 's2',
|
|
81
|
+
cookbook: expect.objectContaining({
|
|
82
|
+
_pointer: { next: 's2' }
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
);
|
|
260
86
|
});
|
|
261
87
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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'
|
|
88
|
+
it('routes next pinned control-flow step to pinned service queue (not workflow.init)', async () => {
|
|
89
|
+
const cookbook = {
|
|
90
|
+
version: '2.1.0',
|
|
91
|
+
api_input: { items: ['a'] },
|
|
92
|
+
steps: [
|
|
93
|
+
{ step_id: 's1', type: 'task', service: 'svc-a', operation: 'op1', input: { x: 1 } },
|
|
94
|
+
{
|
|
95
|
+
step_id: 'loop',
|
|
96
|
+
type: 'foreach',
|
|
97
|
+
service: 'hello-service',
|
|
98
|
+
iterator: '{{api_input.items}}',
|
|
99
|
+
body: [
|
|
100
|
+
{ step_id: 'inner', type: 'task', service: 'hello-service', operation: 'good-day', input: { name: '{{current}}' } }
|
|
101
|
+
]
|
|
289
102
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
orchestrator.processWorkflowMessage(workflow, 'test-service')
|
|
294
|
-
).rejects.toThrow('Connection refused');
|
|
295
|
-
});
|
|
103
|
+
],
|
|
104
|
+
delivery: { handler: 'none' }
|
|
105
|
+
};
|
|
296
106
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// Missing required fields
|
|
300
|
-
workflow_id: 'wf-malformed-123'
|
|
301
|
-
};
|
|
107
|
+
const msg = { workflow_id: 'wf-2', cookbook, current_step: 's1' };
|
|
108
|
+
await orchestrator.processWorkflowMessage(msg, 'svc-a');
|
|
302
109
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
110
|
+
expect(mqClientStub.publish).toHaveBeenCalledWith(
|
|
111
|
+
'hello-service.workflow',
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
workflow_id: 'wf-2',
|
|
114
|
+
current_step: 'loop'
|
|
115
|
+
})
|
|
116
|
+
);
|
|
307
117
|
});
|
|
118
|
+
});
|
|
308
119
|
|
|
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
120
|
|
|
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
|
-
});
|
|
@@ -13,14 +13,18 @@ const CookbookConnector = require('@onlineapps/conn-orch-cookbook');
|
|
|
13
13
|
|
|
14
14
|
// Skip integration tests if external services are not available
|
|
15
15
|
const SKIP_INTEGRATION = process.env.SKIP_INTEGRATION === 'true';
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
16
|
+
const RABBITMQ_URL = process.env.RABBITMQ_URL || null;
|
|
17
|
+
const REGISTRY_URL = process.env.REGISTRY_URL || null;
|
|
18
|
+
const HAS_ENV = !!(RABBITMQ_URL && REGISTRY_URL);
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const describeMaybe = (!SKIP_INTEGRATION && HAS_ENV) ? describe : describe.skip;
|
|
21
|
+
|
|
22
|
+
describeMaybe('Orchestrator - Integration Tests @integration', () => {
|
|
21
23
|
if (SKIP_INTEGRATION) {
|
|
22
|
-
it
|
|
23
|
-
|
|
24
|
+
it('Skipping integration tests (SKIP_INTEGRATION=true)', () => {});
|
|
25
|
+
}
|
|
26
|
+
if (!HAS_ENV) {
|
|
27
|
+
it('Skipping integration tests (missing RABBITMQ_URL/REGISTRY_URL)', () => {});
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
let orchestrator;
|