@onlineapps/conn-orch-orchestrator 1.0.74 → 1.0.76

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/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-orchestrator",
3
- "version": "1.0.74",
3
+ "version": "1.0.76",
4
4
  "description": "Workflow orchestration connector for OA Drive - handles message routing and workflow execution",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "test": "jest --config jest.config.js",
8
+ "test:unit": "jest --config jest.config.js tests/unit",
8
9
  "test:watch": "jest --config jest.config.js --watch",
9
10
  "test:coverage": "jest --config jest.config.js --coverage",
10
11
  "lint": "eslint src/",
@@ -24,7 +25,7 @@
24
25
  "@onlineapps/conn-infra-mq": "1.1.57",
25
26
  "@onlineapps/conn-orch-registry": "1.1.33",
26
27
  "@onlineapps/conn-orch-cookbook": "2.0.16",
27
- "@onlineapps/conn-orch-api-mapper": "1.0.21"
28
+ "@onlineapps/conn-orch-api-mapper": "1.0.23"
28
29
  },
29
30
  "devDependencies": {
30
31
  "jest": "^29.5.0",
@@ -427,6 +427,15 @@ class WorkflowOrchestrator {
427
427
  cookbook: finalCookbook, // UNIFIED cookbook with full state
428
428
  failed_at: new Date().toISOString()
429
429
  });
430
+
431
+ // Compatibility: also publish to workflow.dlq for DLQ dashboards/tools that only look for "*.dlq" queues.
432
+ // workflow.dlq is an infrastructure queue created by Gateway.
433
+ await this.mqClient.publish('workflow.dlq', {
434
+ workflow_id,
435
+ current_step,
436
+ cookbook: finalCookbook,
437
+ failed_at: new Date().toISOString()
438
+ });
430
439
 
431
440
  // ALSO publish to monitoring.workflow so dashboard shows the DLQ entry
432
441
  try {
@@ -1,150 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Component tests for WorkflowOrchestrator (V2.1 unified cookbook message format)
5
- *
6
- * Goal: verify orchestration behavior (task execution + routing) without external MQ/registry.
7
- */
8
-
9
- jest.mock('@onlineapps/mq-client-core', () => ({
10
- monitoring: {
11
- publishToMonitoringWorkflow: jest.fn(async () => true)
12
- }
13
- }));
14
-
15
- const WorkflowOrchestrator = require('../../src/WorkflowOrchestrator');
16
-
17
- describe('WorkflowOrchestrator - Component Tests @component', () => {
18
- let orchestrator;
19
- let mqClientStub;
20
- let registryClientStub;
21
- let apiMapperStub;
22
-
23
- beforeEach(() => {
24
- mqClientStub = {
25
- publish: jest.fn(async () => true)
26
- };
27
-
28
- registryClientStub = {
29
- getService: jest.fn(async () => null),
30
- getAllServices: jest.fn(async () => [])
31
- };
32
-
33
- apiMapperStub = {
34
- callOperation: jest.fn(async (operationId, input) => ({ operationId, input }))
35
- };
36
-
37
- orchestrator = new WorkflowOrchestrator({
38
- mqClient: mqClientStub,
39
- registryClient: registryClientStub,
40
- apiMapper: apiMapperStub,
41
- cookbook: {
42
- validateCookbook: jest.fn(),
43
- templateHelpers: {
44
- normalizeString: (v) => String(v || '').trim()
45
- }
46
- },
47
- logger: {
48
- info: jest.fn(),
49
- error: jest.fn(),
50
- warn: jest.fn(),
51
- debug: jest.fn()
52
- }
53
- });
54
- });
55
-
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
- };
66
-
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
- );
86
- });
87
-
88
- it('routes next pinned control-flow step to pinned service queue (not workflow.init/workflow.control)', 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
- ]
102
- }
103
- ],
104
- delivery: { handler: 'none' }
105
- };
106
-
107
- const msg = { workflow_id: 'wf-2', cookbook, current_step: 's1' };
108
- await orchestrator.processWorkflowMessage(msg, 'svc-a');
109
-
110
- expect(mqClientStub.publish).toHaveBeenCalledWith(
111
- 'hello-service.workflow',
112
- expect.objectContaining({
113
- workflow_id: 'wf-2',
114
- current_step: 'loop'
115
- })
116
- );
117
- });
118
-
119
- it('routes next unpinned control-flow step to workflow.control', async () => {
120
- const cookbook = {
121
- version: '2.1.0',
122
- api_input: { items: ['a'] },
123
- steps: [
124
- { step_id: 's1', type: 'task', service: 'svc-a', operation: 'op1', input: { x: 1 } },
125
- {
126
- step_id: 'loop',
127
- type: 'foreach',
128
- iterator: '{{api_input.items}}',
129
- body: [
130
- { step_id: 'inner', type: 'task', service: 'hello-service', operation: 'good-day', input: { name: '{{current}}' } }
131
- ]
132
- }
133
- ],
134
- delivery: { handler: 'none' }
135
- };
136
-
137
- const msg = { workflow_id: 'wf-3', cookbook, current_step: 's1' };
138
- await orchestrator.processWorkflowMessage(msg, 'svc-a');
139
-
140
- expect(mqClientStub.publish).toHaveBeenCalledWith(
141
- 'workflow.control',
142
- expect.objectContaining({
143
- workflow_id: 'wf-3',
144
- current_step: 'loop'
145
- })
146
- );
147
- });
148
- });
149
-
150
-
@@ -1,318 +0,0 @@
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 || null;
17
- const REGISTRY_URL = process.env.REGISTRY_URL || null;
18
- const HAS_ENV = !!(RABBITMQ_URL && REGISTRY_URL);
19
-
20
- const describeMaybe = (!SKIP_INTEGRATION && HAS_ENV) ? describe : describe.skip;
21
-
22
- describeMaybe('Orchestrator - Integration Tests @integration', () => {
23
- if (SKIP_INTEGRATION) {
24
- it('Skipping integration tests (SKIP_INTEGRATION=true)', () => {});
25
- }
26
- if (!HAS_ENV) {
27
- it('Skipping integration tests (missing RABBITMQ_URL/REGISTRY_URL)', () => {});
28
- }
29
-
30
- let orchestrator;
31
- let mqClient;
32
- let registryClient;
33
-
34
- beforeAll(async () => {
35
- // Setup real connections
36
- try {
37
- mqClient = new MQConnector({
38
- url: RABBITMQ_URL,
39
- serviceName: 'test-orchestrator'
40
- });
41
- await mqClient.connect();
42
-
43
- registryClient = new RegistryConnector.ServiceRegistryClient({
44
- registryUrl: REGISTRY_URL
45
- });
46
-
47
- // Register test service
48
- await registryClient.register({
49
- name: 'test-orchestrator',
50
- url: 'http://localhost:9999',
51
- openapi: {
52
- openapi: '3.0.0',
53
- info: { title: 'Test Service', version: '1.0.0' },
54
- paths: {
55
- '/echo': {
56
- post: {
57
- operationId: 'echo',
58
- requestBody: {
59
- content: {
60
- 'application/json': {
61
- schema: { type: 'object' }
62
- }
63
- }
64
- }
65
- }
66
- }
67
- }
68
- }
69
- });
70
- } catch (error) {
71
- console.warn('Failed to setup integration test environment:', error.message);
72
- console.warn('Skipping integration tests');
73
- return;
74
- }
75
- });
76
-
77
- afterAll(async () => {
78
- // Cleanup
79
- if (orchestrator) {
80
- await orchestrator.shutdown();
81
- }
82
- if (registryClient) {
83
- await registryClient.unregister('test-orchestrator');
84
- }
85
- if (mqClient) {
86
- await mqClient.disconnect();
87
- }
88
- });
89
-
90
- beforeEach(() => {
91
- const apiMapper = ApiMapperConnector.create({
92
- openApiSpec: {
93
- openapi: '3.0.0',
94
- paths: {
95
- '/echo': {
96
- post: {
97
- operationId: 'echo'
98
- }
99
- }
100
- }
101
- },
102
- serviceUrl: 'http://localhost:9999'
103
- });
104
-
105
- orchestrator = WorkflowOrchestrator.create({
106
- mqClient,
107
- registryClient,
108
- apiMapper,
109
- cookbook: CookbookConnector,
110
- logger: {
111
- info: jest.fn(),
112
- error: jest.fn(),
113
- warn: jest.fn(),
114
- debug: jest.fn()
115
- }
116
- });
117
- });
118
-
119
- describe('End-to-End Workflow Execution', () => {
120
- it('should execute a simple workflow end-to-end', async () => {
121
- const workflow = {
122
- workflow_id: 'int-test-' + Date.now(),
123
- current_step: {
124
- id: 'step1',
125
- service: 'test-orchestrator',
126
- operation: 'echo',
127
- input: { message: 'Hello Integration Test' },
128
- output: 'echoResult'
129
- },
130
- context: {
131
- variables: {},
132
- results: {}
133
- }
134
- };
135
-
136
- // Mock the actual HTTP call since we don't have a real service
137
- orchestrator.apiMapper.callOperation = jest.fn().mockResolvedValue({
138
- status: 200,
139
- data: { echoed: 'Hello Integration Test' }
140
- });
141
-
142
- const result = await orchestrator.processWorkflowMessage(
143
- workflow,
144
- 'test-orchestrator'
145
- );
146
-
147
- expect(result).toBeDefined();
148
- expect(result.data).toHaveProperty('echoed');
149
- });
150
-
151
- it('should handle message routing through RabbitMQ', async () => {
152
- const testQueue = 'test.integration.queue';
153
-
154
- // Setup consumer
155
- let receivedMessage = null;
156
- await mqClient.consume(
157
- (message) => {
158
- receivedMessage = message;
159
- return Promise.resolve();
160
- },
161
- { queue: testQueue }
162
- );
163
-
164
- // Send message through orchestrator
165
- const workflow = {
166
- workflow_id: 'route-test-' + Date.now(),
167
- current_step: {
168
- id: 'route',
169
- service: 'test-orchestrator',
170
- operation: 'echo',
171
- input: { test: 'data' }
172
- },
173
- next_steps: [
174
- {
175
- id: 'next',
176
- service: 'next-service',
177
- operation: 'process'
178
- }
179
- ],
180
- context: {}
181
- };
182
-
183
- // Mock the API call
184
- orchestrator.apiMapper.callOperation = jest.fn().mockResolvedValue({
185
- status: 200,
186
- data: { processed: true }
187
- });
188
-
189
- // Override routing to use test queue
190
- orchestrator.routeToNextStep = async (workflowId, step, context) => {
191
- await mqClient.publish(
192
- { workflow_id: workflowId, current_step: step, context },
193
- { queue: testQueue }
194
- );
195
- };
196
-
197
- await orchestrator.processWorkflowMessage(workflow, 'test-orchestrator');
198
-
199
- // Wait for message to be received
200
- await new Promise(resolve => setTimeout(resolve, 100));
201
-
202
- expect(receivedMessage).toBeTruthy();
203
- expect(receivedMessage.workflow_id).toBe(workflow.workflow_id);
204
- });
205
-
206
- it('should interact with registry for service discovery', async () => {
207
- // Get service info from registry
208
- const serviceInfo = await registryClient.getService('test-orchestrator');
209
-
210
- expect(serviceInfo).toBeDefined();
211
- expect(serviceInfo.name).toBe('test-orchestrator');
212
- expect(serviceInfo.openapi).toBeDefined();
213
- });
214
- });
215
-
216
- describe('Fault Tolerance', () => {
217
- it('should handle temporary network failures', async () => {
218
- // Simulate network failure
219
- orchestrator.apiMapper.callOperation = jest.fn()
220
- .mockRejectedValueOnce(new Error('ECONNREFUSED'))
221
- .mockResolvedValueOnce({ status: 200, data: { ok: true } });
222
-
223
- const workflow = {
224
- workflow_id: 'retry-test-' + Date.now(),
225
- current_step: {
226
- service: 'test-orchestrator',
227
- operation: 'echo',
228
- retry: { max: 3, delay: 100 }
229
- }
230
- };
231
-
232
- // With retry logic (if implemented)
233
- const result = await orchestrator.processWorkflowMessage(
234
- workflow,
235
- 'test-orchestrator'
236
- ).catch(e => e);
237
-
238
- // Should eventually succeed or fail after retries
239
- expect(orchestrator.apiMapper.callOperation).toHaveBeenCalled();
240
- });
241
-
242
- it('should handle message acknowledgment correctly', async () => {
243
- const workflow = {
244
- workflow_id: 'ack-test-' + Date.now(),
245
- current_step: {
246
- service: 'test-orchestrator',
247
- operation: 'echo'
248
- }
249
- };
250
-
251
- orchestrator.apiMapper.callOperation = jest.fn()
252
- .mockResolvedValue({ status: 200, data: {} });
253
-
254
- await orchestrator.processWorkflowMessage(workflow, 'test-orchestrator');
255
-
256
- // Message should be processed without errors
257
- expect(orchestrator.apiMapper.callOperation).toHaveBeenCalled();
258
- });
259
- });
260
-
261
- describe('Performance', () => {
262
- it('should handle multiple concurrent workflows', async () => {
263
- const workflows = Array.from({ length: 10 }, (_, i) => ({
264
- workflow_id: `perf-test-${Date.now()}-${i}`,
265
- current_step: {
266
- service: 'test-orchestrator',
267
- operation: 'echo',
268
- input: { index: i }
269
- }
270
- }));
271
-
272
- orchestrator.apiMapper.callOperation = jest.fn()
273
- .mockResolvedValue({ status: 200, data: {} });
274
-
275
- const startTime = Date.now();
276
-
277
- await Promise.all(
278
- workflows.map(w =>
279
- orchestrator.processWorkflowMessage(w, 'test-orchestrator')
280
- )
281
- );
282
-
283
- const duration = Date.now() - startTime;
284
-
285
- expect(orchestrator.apiMapper.callOperation).toHaveBeenCalledTimes(10);
286
- expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
287
- });
288
-
289
- it('should not leak memory during long-running operations', async () => {
290
- const initialMemory = process.memoryUsage().heapUsed;
291
-
292
- // Process many workflows
293
- for (let i = 0; i < 100; i++) {
294
- const workflow = {
295
- workflow_id: `mem-test-${i}`,
296
- current_step: {
297
- service: 'test-orchestrator',
298
- operation: 'echo',
299
- input: { data: 'x'.repeat(1000) } // 1KB of data
300
- }
301
- };
302
-
303
- orchestrator.apiMapper.callOperation = jest.fn()
304
- .mockResolvedValue({ status: 200, data: {} });
305
-
306
- await orchestrator.processWorkflowMessage(workflow, 'test-orchestrator');
307
- }
308
-
309
- global.gc && global.gc(); // Force garbage collection if available
310
-
311
- const finalMemory = process.memoryUsage().heapUsed;
312
- const memoryGrowth = finalMemory - initialMemory;
313
-
314
- // Memory growth should be reasonable (< 50MB)
315
- expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
316
- });
317
- });
318
- });
@@ -1,254 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Unit tests for WorkflowOrchestrator
5
- * Tests individual methods in isolation with mocked dependencies
6
- */
7
-
8
- jest.mock('@onlineapps/mq-client-core', () => ({
9
- monitoring: {
10
- publishToMonitoringWorkflow: jest.fn(async () => true)
11
- }
12
- }));
13
-
14
- const WorkflowOrchestrator = require('../../src/WorkflowOrchestrator');
15
-
16
- describe('WorkflowOrchestrator - Unit Tests @unit', () => {
17
- let orchestrator;
18
- let mockMqClient;
19
- let mockRegistryClient;
20
- let mockApiMapper;
21
- let mockCookbook;
22
- let mockLogger;
23
- let mockCache;
24
- let mockErrorHandler;
25
-
26
- beforeEach(() => {
27
- // Setup mocked dependencies
28
- mockMqClient = {
29
- publish: jest.fn().mockResolvedValue(true),
30
- subscribe: jest.fn().mockResolvedValue(true)
31
- };
32
-
33
- mockRegistryClient = {
34
- getService: jest.fn().mockResolvedValue({
35
- name: 'test-service',
36
- url: 'http://localhost:3000',
37
- openapi: {}
38
- })
39
- };
40
-
41
- mockApiMapper = {
42
- callOperation: jest.fn(async (operationId, input, ctx) => ({
43
- operationId,
44
- input,
45
- ctx
46
- }))
47
- };
48
-
49
- mockCookbook = {
50
- validateCookbook: jest.fn(),
51
- templateHelpers: {
52
- normalizeString: jest.fn((value) => String(value || '').replace(/\s+/g, ' ').trim()),
53
- webalizeString: jest.fn((value) => String(value || '').toLowerCase().replace(/\s+/g, '-')),
54
- string2file: jest.fn(async (value) => ({ _descriptor: true, type: 'file', storage_ref: `minio://mock/${String(value).length}` })),
55
- file2string: jest.fn(async (descriptor) => (descriptor && descriptor.storage_ref ? `from:${descriptor.storage_ref}` : String(descriptor)))
56
- }
57
- };
58
-
59
- mockLogger = {
60
- info: jest.fn(),
61
- error: jest.fn(),
62
- warn: jest.fn(),
63
- debug: jest.fn()
64
- };
65
-
66
- mockCache = {
67
- get: jest.fn().mockResolvedValue(null),
68
- set: jest.fn().mockResolvedValue('OK'),
69
- del: jest.fn().mockResolvedValue(1)
70
- };
71
-
72
- mockErrorHandler = {
73
- executeWithRetry: jest.fn((fn) => fn()),
74
- handleError: jest.fn(),
75
- isRetryableError: jest.fn().mockReturnValue(false)
76
- };
77
-
78
- orchestrator = new WorkflowOrchestrator({
79
- mqClient: mockMqClient,
80
- registryClient: mockRegistryClient,
81
- apiMapper: mockApiMapper,
82
- cookbook: mockCookbook,
83
- logger: mockLogger,
84
- cache: mockCache,
85
- errorHandler: mockErrorHandler
86
- });
87
- });
88
-
89
- describe('constructor', () => {
90
- it('should initialize with all required dependencies', () => {
91
- expect(orchestrator.mqClient).toBe(mockMqClient);
92
- expect(orchestrator.registryClient).toBe(mockRegistryClient);
93
- expect(orchestrator.apiMapper).toBe(mockApiMapper);
94
- expect(orchestrator.cookbook).toBe(mockCookbook);
95
- expect(orchestrator.logger).toBe(mockLogger);
96
- expect(orchestrator.cache).toBe(mockCache);
97
- expect(orchestrator.errorHandler).toBe(mockErrorHandler);
98
- });
99
-
100
- it('should throw if required dependencies are missing', () => {
101
- expect(() => new WorkflowOrchestrator({})).toThrow();
102
- });
103
- });
104
-
105
- describe('processWorkflowMessage', () => {
106
- it('executes task step for the current service and calls ApiMapper', async () => {
107
- const cookbook = {
108
- version: '2.1.0',
109
- api_input: { text: 'hello' },
110
- steps: [
111
- {
112
- step_id: 's1',
113
- type: 'task',
114
- service: 'hello-service',
115
- operation: 'test-echo',
116
- input: { text: '{{api_input.text}}' }
117
- }
118
- ],
119
- delivery: { handler: 'none' }
120
- };
121
-
122
- const message = { workflow_id: 'wf-123', cookbook, current_step: 's1' };
123
- const result = await orchestrator.processWorkflowMessage(message, 'hello-service');
124
-
125
- expect(mockApiMapper.callOperation).toHaveBeenCalledWith(
126
- 'test-echo',
127
- { text: 'hello' },
128
- expect.objectContaining({ workflow_id: 'wf-123', step_id: 's1' })
129
- );
130
- expect(result.success).toBe(true);
131
- });
132
-
133
- it('routes task step to correct service queue when service does not match', async () => {
134
- const cookbook = {
135
- version: '2.1.0',
136
- api_input: { text: 'hello' },
137
- steps: [
138
- {
139
- step_id: 's1',
140
- type: 'task',
141
- service: 'other-service',
142
- operation: 'test-echo',
143
- input: { text: '{{api_input.text}}' }
144
- }
145
- ],
146
- delivery: { handler: 'none' }
147
- };
148
-
149
- const message = { workflow_id: 'wf-123', cookbook, current_step: 's1' };
150
- const res = await orchestrator.processWorkflowMessage(message, 'hello-service');
151
-
152
- expect(res).toEqual({ skipped: true, reason: 'wrong_service' });
153
- expect(mockMqClient.publish).toHaveBeenCalledWith('other-service.workflow', message);
154
- expect(mockApiMapper.callOperation).not.toHaveBeenCalled();
155
- });
156
-
157
- it('routes pinned control-flow step to pinned service when current service does not match', async () => {
158
- const cookbook = {
159
- version: '2.1.0',
160
- api_input: { items: ['A', 'B'] },
161
- steps: [
162
- {
163
- step_id: 'loop',
164
- type: 'foreach',
165
- service: 'hello-service', // pin
166
- iterator: '{{api_input.items}}',
167
- body: [
168
- {
169
- step_id: 'inner',
170
- type: 'task',
171
- service: 'hello-service',
172
- operation: 'good-day',
173
- input: { name: '{{current}}' }
174
- }
175
- ]
176
- }
177
- ],
178
- delivery: { handler: 'none' }
179
- };
180
-
181
- const message = { workflow_id: 'wf-123', cookbook, current_step: 'loop' };
182
- const res = await orchestrator.processWorkflowMessage(message, 'pdfgen-service');
183
-
184
- expect(res).toEqual({ skipped: true, reason: 'wrong_service_control_flow' });
185
- expect(mockMqClient.publish).toHaveBeenCalledWith('hello-service.workflow', message);
186
- expect(mockApiMapper.callOperation).not.toHaveBeenCalled();
187
- });
188
-
189
- it('executes foreach control-flow step when pinned to this service', async () => {
190
- const cookbook = {
191
- version: '2.1.0',
192
- api_input: { items: ['A', 'B'] },
193
- steps: [
194
- {
195
- step_id: 'loop',
196
- type: 'foreach',
197
- service: 'hello-service', // pin to ensure deterministic executor
198
- iterator: '{{api_input.items}}',
199
- body: [
200
- {
201
- step_id: 'inner',
202
- type: 'task',
203
- service: 'hello-service',
204
- operation: 'good-day',
205
- input: { name: '{{current}}' }
206
- }
207
- ]
208
- }
209
- ],
210
- delivery: { handler: 'none' }
211
- };
212
-
213
- const message = { workflow_id: 'wf-123', cookbook, current_step: 'loop' };
214
- const res = await orchestrator.processWorkflowMessage(message, 'hello-service');
215
-
216
- expect(res.success).toBe(true);
217
- expect(res.result).toEqual(expect.objectContaining({ type: 'foreach', count: 2 }));
218
- expect(mockApiMapper.callOperation).toHaveBeenCalledTimes(2);
219
- expect(mockApiMapper.callOperation).toHaveBeenNthCalledWith(
220
- 1,
221
- 'good-day',
222
- { name: 'A' },
223
- expect.objectContaining({ workflow_id: 'wf-123', step_id: 'inner' })
224
- );
225
- });
226
-
227
- it('resolves helper call in templates', async () => {
228
- const cookbook = {
229
- version: '2.1.0',
230
- api_input: { text: ' Hello World ' },
231
- steps: [
232
- {
233
- step_id: 's1',
234
- type: 'task',
235
- service: 'hello-service',
236
- operation: 'test-echo',
237
- input: { text: '{{normalizeString(api_input.text)}}' }
238
- }
239
- ],
240
- delivery: { handler: 'none' }
241
- };
242
-
243
- const message = { workflow_id: 'wf-123', cookbook, current_step: 's1' };
244
- await orchestrator.processWorkflowMessage(message, 'hello-service');
245
-
246
- expect(mockCookbook.templateHelpers.normalizeString).toHaveBeenCalled();
247
- expect(mockApiMapper.callOperation).toHaveBeenCalledWith(
248
- 'test-echo',
249
- { text: 'Hello World' },
250
- expect.objectContaining({ workflow_id: 'wf-123', step_id: 's1' })
251
- );
252
- });
253
- });
254
- });