@squiz/dx-common-lib 1.72.1 → 1.72.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/lib/events/EventBusService.d.ts +107 -0
- package/lib/events/EventBusService.js +196 -0
- package/lib/events/EventBusService.js.map +1 -0
- package/lib/events/EventBusService.spec.d.ts +1 -0
- package/lib/events/EventBusService.spec.js +527 -0
- package/lib/events/EventBusService.spec.js.map +1 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -1
- package/lib/stream/EventStreamHandler.d.ts +38 -0
- package/lib/stream/EventStreamHandler.js +128 -0
- package/lib/stream/EventStreamHandler.js.map +1 -0
- package/lib/stream/EventStreamHandler.spec.d.ts +1 -0
- package/lib/stream/EventStreamHandler.spec.js +364 -0
- package/lib/stream/EventStreamHandler.spec.js.map +1 -0
- package/lib/stream/StreamUtils.d.ts +38 -0
- package/lib/stream/StreamUtils.js +59 -0
- package/lib/stream/StreamUtils.js.map +1 -0
- package/lib/stream/StreamUtils.spec.d.ts +1 -0
- package/lib/stream/StreamUtils.spec.js +92 -0
- package/lib/stream/StreamUtils.spec.js.map +1 -0
- package/package.json +3 -2
- package/src/events/EventBusService.spec.ts +707 -0
- package/src/events/EventBusService.ts +316 -0
- package/src/index.ts +3 -0
- package/src/stream/EventStreamHandler.spec.ts +440 -0
- package/src/stream/EventStreamHandler.ts +192 -0
- package/src/stream/StreamUtils.spec.ts +113 -0
- package/src/stream/StreamUtils.ts +58 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { EventBusService, EventBusConfig } from './EventBusService';
|
|
2
|
+
|
|
3
|
+
interface MockLogger {
|
|
4
|
+
error: jest.Mock;
|
|
5
|
+
warn: jest.Mock;
|
|
6
|
+
info: jest.Mock;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('EventBusService', () => {
|
|
10
|
+
let eventBusService: EventBusService;
|
|
11
|
+
let mockLogger: MockLogger;
|
|
12
|
+
let mockConfig: EventBusConfig;
|
|
13
|
+
let mockFetch: jest.Mock;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Create mock logger
|
|
17
|
+
mockLogger = {
|
|
18
|
+
error: jest.fn(),
|
|
19
|
+
warn: jest.fn(),
|
|
20
|
+
info: jest.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Create mock config
|
|
24
|
+
mockConfig = {
|
|
25
|
+
eventBusUrl: 'https://test-event-bus.com',
|
|
26
|
+
eventBusKey: 'test-api-key',
|
|
27
|
+
tenantId: 'test-tenant',
|
|
28
|
+
deploymentEnvironment: 'test',
|
|
29
|
+
squizRegion: 'us',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Mock fetch globally
|
|
33
|
+
const mockApiResponse = [
|
|
34
|
+
{
|
|
35
|
+
statusCode: 202,
|
|
36
|
+
event: {
|
|
37
|
+
id: '12345678-1234-5678-9abc-123456789012',
|
|
38
|
+
source: 'component-service',
|
|
39
|
+
detailType: 'component.change.create',
|
|
40
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
41
|
+
detail: {
|
|
42
|
+
metadata: { action: 'component.change.create' },
|
|
43
|
+
event: { name: 'test-component' },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
mockFetch = jest.fn().mockResolvedValue({
|
|
50
|
+
ok: true,
|
|
51
|
+
json: jest.fn().mockResolvedValue(mockApiResponse),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
global.fetch = mockFetch;
|
|
55
|
+
|
|
56
|
+
eventBusService = new EventBusService(mockLogger as any, mockConfig);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
jest.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('publishEvent', () => {
|
|
64
|
+
const testDetailType = 'component.change.create';
|
|
65
|
+
const testDetail = { name: 'test-component', version: '1.0.0' };
|
|
66
|
+
|
|
67
|
+
it('should successfully publish an event and return API response', async () => {
|
|
68
|
+
// Mock Date constructor to make eventTime predictable
|
|
69
|
+
const mockDate = new Date('2023-01-01T00:00:00.000Z');
|
|
70
|
+
const originalDate = Date;
|
|
71
|
+
(global as any).Date = jest.fn(() => mockDate);
|
|
72
|
+
global.Date.now = originalDate.now;
|
|
73
|
+
|
|
74
|
+
const response = await eventBusService.publishEvent(testDetailType, testDetail);
|
|
75
|
+
|
|
76
|
+
// Verify fetch was called with correct URL
|
|
77
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
78
|
+
'https://test-event-bus.com/events',
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'x-api-key': 'test-api-key',
|
|
83
|
+
'x-dxp-tenant': 'test-tenant',
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Verify the payload structure
|
|
90
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
91
|
+
const payload = JSON.parse(callArgs[1].body);
|
|
92
|
+
|
|
93
|
+
expect(payload).toHaveLength(1);
|
|
94
|
+
expect(payload[0]).toMatchObject({
|
|
95
|
+
source: 'component-service',
|
|
96
|
+
'detail-type': testDetailType,
|
|
97
|
+
action: testDetailType,
|
|
98
|
+
eventTime: '2023-01-01T00:00:00.000Z',
|
|
99
|
+
metadata: {
|
|
100
|
+
tenantID: 'test-tenant',
|
|
101
|
+
environment: 'test',
|
|
102
|
+
region: 'us',
|
|
103
|
+
},
|
|
104
|
+
event: testDetail,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(mockLogger.info).not.toHaveBeenCalled();
|
|
108
|
+
|
|
109
|
+
// Verify the response is returned correctly
|
|
110
|
+
expect(response).toEqual([
|
|
111
|
+
{
|
|
112
|
+
statusCode: 202,
|
|
113
|
+
event: {
|
|
114
|
+
id: '12345678-1234-5678-9abc-123456789012',
|
|
115
|
+
source: 'component-service',
|
|
116
|
+
detailType: 'component.change.create',
|
|
117
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
118
|
+
detail: {
|
|
119
|
+
metadata: { action: 'component.change.create' },
|
|
120
|
+
event: { name: 'test-component' },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
// Restore Date
|
|
127
|
+
global.Date = originalDate;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should throw error when eventBusUrl is not configured', async () => {
|
|
131
|
+
// Create service with config missing URL
|
|
132
|
+
const configWithoutUrl = { ...mockConfig, eventBusUrl: '' };
|
|
133
|
+
const serviceWithoutUrl = new EventBusService(mockLogger as any, configWithoutUrl);
|
|
134
|
+
|
|
135
|
+
await expect(serviceWithoutUrl.publishEvent(testDetailType, testDetail)).rejects.toThrow(
|
|
136
|
+
/Event Bus is not configured: missing EVENT_BUS_URL/,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should throw error when eventBusKey is not configured', async () => {
|
|
143
|
+
// Create service with config missing key
|
|
144
|
+
const configWithoutKey = { ...mockConfig, eventBusKey: '' };
|
|
145
|
+
const serviceWithoutKey = new EventBusService(mockLogger as any, configWithoutKey);
|
|
146
|
+
|
|
147
|
+
await expect(serviceWithoutKey.publishEvent(testDetailType, testDetail)).rejects.toThrow(
|
|
148
|
+
/Event Bus is not configured: missing EVENT_BUS_KEY/,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should throw error when tenantId is not set', async () => {
|
|
155
|
+
// Create service with config missing tenantId
|
|
156
|
+
const configWithoutTenantId = { ...mockConfig, tenantId: '' };
|
|
157
|
+
const serviceWithoutTenantId = new EventBusService(mockLogger as any, configWithoutTenantId);
|
|
158
|
+
|
|
159
|
+
await expect(serviceWithoutTenantId.publishEvent(testDetailType, testDetail)).rejects.toThrow(
|
|
160
|
+
/Tenant ID is not set/,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should handle HTTP errors when response is not ok', async () => {
|
|
167
|
+
mockFetch.mockResolvedValueOnce({
|
|
168
|
+
ok: false,
|
|
169
|
+
status: 500,
|
|
170
|
+
statusText: 'Internal Server Error',
|
|
171
|
+
text: jest.fn().mockResolvedValue('upstream failure body'),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await expect(eventBusService.publishEvent(testDetailType, testDetail)).rejects.toThrow(
|
|
175
|
+
'Event Bus API error: HTTP 500 Internal Server Error for POST https://test-event-bus.com/events. Response body (preview): upstream failure body',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should handle network errors', async () => {
|
|
180
|
+
const networkError = new Error('Network error');
|
|
181
|
+
mockFetch.mockRejectedValueOnce(networkError);
|
|
182
|
+
|
|
183
|
+
await expect(eventBusService.publishEvent(testDetailType, testDetail)).rejects.toThrow('Network error');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('publishEventSafely', () => {
|
|
188
|
+
const testDetailType = 'component.change.update';
|
|
189
|
+
const testDetail = { name: 'test-component', version: '2.0.0' };
|
|
190
|
+
|
|
191
|
+
it('should successfully publish an event and log success with event ID', async () => {
|
|
192
|
+
await expect(eventBusService.publishEventSafely(testDetailType, testDetail)).resolves.not.toThrow();
|
|
193
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
194
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
195
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
196
|
+
`Successfully published event: ${testDetailType} with IDs [12345678-1234-5678-9abc-123456789012] to https://test-event-bus.com/events`,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should fail silently when API returns non-202 status codes', async () => {
|
|
201
|
+
// Mock API response with failed status
|
|
202
|
+
const failedResponse = [
|
|
203
|
+
{
|
|
204
|
+
statusCode: 400,
|
|
205
|
+
event: {
|
|
206
|
+
id: 'failed-event-id',
|
|
207
|
+
source: 'component-service',
|
|
208
|
+
detailType: 'component.change.update',
|
|
209
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
210
|
+
detail: { error: 'Invalid event data' },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
mockFetch.mockResolvedValueOnce({
|
|
215
|
+
ok: true,
|
|
216
|
+
json: jest.fn().mockResolvedValue(failedResponse),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await expect(eventBusService.publishEventSafely(testDetailType, testDetail)).resolves.not.toThrow();
|
|
220
|
+
|
|
221
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
222
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
223
|
+
`Event publishing failed: received non-valid status codes [400] for event ${testDetailType}`,
|
|
224
|
+
);
|
|
225
|
+
expect(mockLogger.info).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should fail silently when API returns mixed status codes', async () => {
|
|
229
|
+
// Mock API response with mixed statuses (batch)
|
|
230
|
+
const mixedResponse = [
|
|
231
|
+
{
|
|
232
|
+
statusCode: 202,
|
|
233
|
+
event: {
|
|
234
|
+
id: 'success-id',
|
|
235
|
+
source: 'component-service',
|
|
236
|
+
detailType: 'test',
|
|
237
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
238
|
+
detail: {},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
statusCode: 400,
|
|
243
|
+
event: {
|
|
244
|
+
id: 'failed-id',
|
|
245
|
+
source: 'component-service',
|
|
246
|
+
detailType: 'test',
|
|
247
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
248
|
+
detail: {},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
statusCode: 500,
|
|
253
|
+
event: {
|
|
254
|
+
id: 'error-id',
|
|
255
|
+
source: 'component-service',
|
|
256
|
+
detailType: 'test',
|
|
257
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
258
|
+
detail: {},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
mockFetch.mockResolvedValueOnce({
|
|
263
|
+
ok: true,
|
|
264
|
+
json: jest.fn().mockResolvedValue(mixedResponse),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await expect(eventBusService.publishEventSafely(testDetailType, testDetail)).resolves.not.toThrow();
|
|
268
|
+
|
|
269
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
270
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
271
|
+
`Event publishing failed: received non-valid status codes [400, 500] for event ${testDetailType}`,
|
|
272
|
+
);
|
|
273
|
+
expect(mockLogger.info).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should catch errors and log them via the logger without throwing', async () => {
|
|
277
|
+
const publishError = new Error('Publish failed');
|
|
278
|
+
jest.spyOn(eventBusService, 'publishEvent').mockRejectedValue(publishError);
|
|
279
|
+
|
|
280
|
+
await expect(eventBusService.publishEventSafely(testDetailType, testDetail)).resolves.not.toThrow();
|
|
281
|
+
|
|
282
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
283
|
+
`Failed to publish event ${testDetailType} to event bus: Publish failed`,
|
|
284
|
+
expect.objectContaining({ detailType: testDetailType }),
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should handle Error objects in safe error logging', async () => {
|
|
289
|
+
const publishError = new Error('Connection timeout');
|
|
290
|
+
jest.spyOn(eventBusService, 'publishEvent').mockRejectedValue(publishError);
|
|
291
|
+
|
|
292
|
+
await eventBusService.publishEventSafely(testDetailType, testDetail);
|
|
293
|
+
|
|
294
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
295
|
+
`Failed to publish event ${testDetailType} to event bus: Connection timeout`,
|
|
296
|
+
expect.objectContaining({ detailType: testDetailType }),
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should handle string errors in safe error logging', async () => {
|
|
301
|
+
const publishError = 'String error message';
|
|
302
|
+
jest.spyOn(eventBusService, 'publishEvent').mockRejectedValue(publishError);
|
|
303
|
+
|
|
304
|
+
await eventBusService.publishEventSafely(testDetailType, testDetail);
|
|
305
|
+
|
|
306
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
307
|
+
`Failed to publish event ${testDetailType} to event bus: String error message`,
|
|
308
|
+
expect.objectContaining({ detailType: testDetailType }),
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should handle complex objects in safe error logging', async () => {
|
|
313
|
+
const publishError = { status: 500, data: { error: 'Internal error' } };
|
|
314
|
+
jest.spyOn(eventBusService, 'publishEvent').mockRejectedValue(publishError);
|
|
315
|
+
|
|
316
|
+
await eventBusService.publishEventSafely(testDetailType, testDetail);
|
|
317
|
+
|
|
318
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
319
|
+
`Failed to publish event ${testDetailType} to event bus: ${JSON.stringify(publishError)}`,
|
|
320
|
+
expect.objectContaining({ detailType: testDetailType }),
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should log error when eventBusUrl is not configured', async () => {
|
|
325
|
+
const configWithoutUrl = { ...mockConfig, eventBusUrl: '' };
|
|
326
|
+
const serviceWithoutUrl = new EventBusService(mockLogger as any, configWithoutUrl);
|
|
327
|
+
|
|
328
|
+
await serviceWithoutUrl.publishEventSafely(testDetailType, testDetail);
|
|
329
|
+
|
|
330
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
331
|
+
expect.stringMatching(
|
|
332
|
+
/Failed to publish event component\.change\.update to event bus: Event Bus is not configured: missing EVENT_BUS_URL/,
|
|
333
|
+
),
|
|
334
|
+
expect.objectContaining({ detailType: 'component.change.update' }),
|
|
335
|
+
);
|
|
336
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
337
|
+
expect(mockLogger.info).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should log error when eventBusKey is not configured', async () => {
|
|
341
|
+
const configWithoutKey = { ...mockConfig, eventBusKey: '' };
|
|
342
|
+
const serviceWithoutKey = new EventBusService(mockLogger as any, configWithoutKey);
|
|
343
|
+
|
|
344
|
+
await serviceWithoutKey.publishEventSafely(testDetailType, testDetail);
|
|
345
|
+
|
|
346
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
347
|
+
expect.stringMatching(
|
|
348
|
+
/Failed to publish event component\.change\.update to event bus: Event Bus is not configured: missing EVENT_BUS_KEY/,
|
|
349
|
+
),
|
|
350
|
+
expect.objectContaining({ detailType: 'component.change.update' }),
|
|
351
|
+
);
|
|
352
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
353
|
+
expect(mockLogger.info).not.toHaveBeenCalled();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should log error when tenantId is not set', async () => {
|
|
357
|
+
const configWithoutTenantId = { ...mockConfig, tenantId: '' };
|
|
358
|
+
const serviceWithoutTenantId = new EventBusService(mockLogger as any, configWithoutTenantId);
|
|
359
|
+
|
|
360
|
+
await serviceWithoutTenantId.publishEventSafely(testDetailType, testDetail);
|
|
361
|
+
|
|
362
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
363
|
+
expect.stringMatching(/Failed to publish event component\.change\.update to event bus: Tenant ID is not set/),
|
|
364
|
+
expect.objectContaining({ detailType: 'component.change.update' }),
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('event payload structure', () => {
|
|
370
|
+
it('should create correct payload structure with all required fields', async () => {
|
|
371
|
+
const testDetailType = 'test.event';
|
|
372
|
+
const testDetail = { foo: 'bar', nested: { key: 'value' } };
|
|
373
|
+
|
|
374
|
+
await eventBusService.publishEvent(testDetailType, testDetail);
|
|
375
|
+
|
|
376
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
377
|
+
const payload = JSON.parse(callArgs[1].body);
|
|
378
|
+
|
|
379
|
+
expect(payload).toHaveLength(1);
|
|
380
|
+
|
|
381
|
+
const event = payload[0];
|
|
382
|
+
expect(event).toMatchObject({
|
|
383
|
+
source: 'component-service',
|
|
384
|
+
'detail-type': testDetailType,
|
|
385
|
+
action: testDetailType,
|
|
386
|
+
metadata: {
|
|
387
|
+
tenantID: 'test-tenant',
|
|
388
|
+
environment: 'test',
|
|
389
|
+
region: 'us',
|
|
390
|
+
},
|
|
391
|
+
event: testDetail,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Verify eventTime is a valid ISO string
|
|
395
|
+
expect(event.eventTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should include the event object in the request payload', async () => {
|
|
399
|
+
const complexDetail = {
|
|
400
|
+
componentName: 'my-component',
|
|
401
|
+
version: '1.2.3',
|
|
402
|
+
metadata: { author: 'test-user' },
|
|
403
|
+
tags: ['frontend', 'react'],
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
await eventBusService.publishEvent('component.created', complexDetail);
|
|
407
|
+
|
|
408
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
409
|
+
const payload = JSON.parse(callArgs[1].body);
|
|
410
|
+
expect(payload[0].event).toEqual(complexDetail);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('integration with config', () => {
|
|
415
|
+
it('should use config values for URL and headers', async () => {
|
|
416
|
+
await eventBusService.publishEvent('test.event', {});
|
|
417
|
+
|
|
418
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
419
|
+
'https://test-event-bus.com/events',
|
|
420
|
+
expect.objectContaining({
|
|
421
|
+
headers: {
|
|
422
|
+
'x-api-key': 'test-api-key',
|
|
423
|
+
'x-dxp-tenant': 'test-tenant',
|
|
424
|
+
'Content-Type': 'application/json',
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should include config values in event metadata', async () => {
|
|
431
|
+
await eventBusService.publishEvent('test.event', {});
|
|
432
|
+
|
|
433
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
434
|
+
const payload = JSON.parse(callArgs[1].body);
|
|
435
|
+
const event = payload[0];
|
|
436
|
+
|
|
437
|
+
expect(event.metadata.tenantID).toBe('test-tenant');
|
|
438
|
+
expect(event.metadata.environment).toBe('test');
|
|
439
|
+
expect(event.metadata.region).toBe('us');
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('publishEvents', () => {
|
|
444
|
+
it('should successfully publish multiple events and return API response', async () => {
|
|
445
|
+
const events = [
|
|
446
|
+
{ detailType: 'component.change.create', detail: { name: 'comp1', version: '1.0.0' } },
|
|
447
|
+
{ detailType: 'component.change.update', detail: { name: 'comp2', version: '2.0.0' } },
|
|
448
|
+
{ detailType: 'component.change.delete', detail: { name: 'comp3', version: '3.0.0' } },
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
const mockApiResponse = [
|
|
452
|
+
{
|
|
453
|
+
statusCode: 202,
|
|
454
|
+
event: {
|
|
455
|
+
id: 'event-1',
|
|
456
|
+
source: 'component-service',
|
|
457
|
+
detailType: 'component.change.create',
|
|
458
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
459
|
+
detail: { metadata: {}, event: {} },
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
statusCode: 202,
|
|
464
|
+
event: {
|
|
465
|
+
id: 'event-2',
|
|
466
|
+
source: 'component-service',
|
|
467
|
+
detailType: 'component.change.update',
|
|
468
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
469
|
+
detail: { metadata: {}, event: {} },
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
statusCode: 202,
|
|
474
|
+
event: {
|
|
475
|
+
id: 'event-3',
|
|
476
|
+
source: 'component-service',
|
|
477
|
+
detailType: 'component.change.delete',
|
|
478
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
479
|
+
detail: { metadata: {}, event: {} },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
mockFetch.mockResolvedValueOnce({
|
|
485
|
+
ok: true,
|
|
486
|
+
json: jest.fn().mockResolvedValue(mockApiResponse),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const response = await eventBusService.publishEvents(events);
|
|
490
|
+
|
|
491
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
492
|
+
expect(response).toEqual(mockApiResponse);
|
|
493
|
+
|
|
494
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
495
|
+
const payload = JSON.parse(callArgs[1].body);
|
|
496
|
+
expect(payload).toHaveLength(3);
|
|
497
|
+
expect(payload[0]['detail-type']).toBe('component.change.create');
|
|
498
|
+
expect(payload[1]['detail-type']).toBe('component.change.update');
|
|
499
|
+
expect(payload[2]['detail-type']).toBe('component.change.delete');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should return empty array when events array is empty', async () => {
|
|
503
|
+
const response = await eventBusService.publishEvents([]);
|
|
504
|
+
|
|
505
|
+
expect(response).toEqual([]);
|
|
506
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('should throw error when tenantId is not set', async () => {
|
|
510
|
+
const configWithoutTenantId = { ...mockConfig, tenantId: '' };
|
|
511
|
+
const serviceWithoutTenantId = new EventBusService(mockLogger as any, configWithoutTenantId);
|
|
512
|
+
|
|
513
|
+
const events = [{ detailType: 'test.event', detail: { test: 'data' } }];
|
|
514
|
+
|
|
515
|
+
await expect(serviceWithoutTenantId.publishEvents(events)).rejects.toThrow(/Tenant ID is not set/);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should throw error when eventBusUrl is not configured', async () => {
|
|
519
|
+
const configWithoutUrl = { ...mockConfig, eventBusUrl: '' };
|
|
520
|
+
const serviceWithoutUrl = new EventBusService(mockLogger as any, configWithoutUrl);
|
|
521
|
+
|
|
522
|
+
const events = [{ detailType: 'test.event', detail: { test: 'data' } }];
|
|
523
|
+
|
|
524
|
+
await expect(serviceWithoutUrl.publishEvents(events)).rejects.toThrow(
|
|
525
|
+
/Event Bus is not configured: missing EVENT_BUS_URL/,
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('should throw error when HTTP response is not ok', async () => {
|
|
530
|
+
mockFetch.mockResolvedValueOnce({
|
|
531
|
+
ok: false,
|
|
532
|
+
status: 400,
|
|
533
|
+
statusText: 'Bad Request',
|
|
534
|
+
text: jest.fn().mockResolvedValue('bad request detail'),
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const events = [{ detailType: 'test.event', detail: { test: 'data' } }];
|
|
538
|
+
|
|
539
|
+
await expect(eventBusService.publishEvents(events)).rejects.toThrow(
|
|
540
|
+
'Event Bus API error: HTTP 400 Bad Request for POST https://test-event-bus.com/events. Response body (preview): bad request detail',
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe('publishEventsSafely', () => {
|
|
546
|
+
it('should successfully publish multiple events and log success', async () => {
|
|
547
|
+
const events = [
|
|
548
|
+
{ detailType: 'component.change.create', detail: { name: 'comp1', version: '1.0.0' } },
|
|
549
|
+
{ detailType: 'component.change.update', detail: { name: 'comp2', version: '2.0.0' } },
|
|
550
|
+
];
|
|
551
|
+
|
|
552
|
+
const mockApiResponse = [
|
|
553
|
+
{
|
|
554
|
+
statusCode: 202,
|
|
555
|
+
event: {
|
|
556
|
+
id: 'event-1',
|
|
557
|
+
source: 'component-service',
|
|
558
|
+
detailType: 'test',
|
|
559
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
560
|
+
detail: {},
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
statusCode: 202,
|
|
565
|
+
event: {
|
|
566
|
+
id: 'event-2',
|
|
567
|
+
source: 'component-service',
|
|
568
|
+
detailType: 'test',
|
|
569
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
570
|
+
detail: {},
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
mockFetch.mockResolvedValueOnce({
|
|
576
|
+
ok: true,
|
|
577
|
+
json: jest.fn().mockResolvedValue(mockApiResponse),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
await expect(eventBusService.publishEventsSafely(events)).resolves.not.toThrow();
|
|
581
|
+
|
|
582
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
583
|
+
`Successfully published batch of ${events.length} events with IDs [event-1, event-2] to https://test-event-bus.com/events`,
|
|
584
|
+
);
|
|
585
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('should throw error when batch publishing fails', async () => {
|
|
589
|
+
const events = [
|
|
590
|
+
{ detailType: 'component.change.create', detail: { name: 'comp1', version: '1.0.0' } },
|
|
591
|
+
{ detailType: 'component.change.update', detail: { name: 'comp2', version: '2.0.0' } },
|
|
592
|
+
];
|
|
593
|
+
|
|
594
|
+
mockFetch.mockResolvedValueOnce({
|
|
595
|
+
ok: false,
|
|
596
|
+
status: 500,
|
|
597
|
+
statusText: 'Internal Server Error',
|
|
598
|
+
text: jest.fn().mockResolvedValue('batch error body'),
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await expect(eventBusService.publishEventsSafely(events)).rejects.toThrow(
|
|
602
|
+
/Event Bus API error: HTTP 500 Internal Server Error for POST https:\/\/test-event-bus\.com\/events/,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should throw error when one or more events in batch fail', async () => {
|
|
609
|
+
const events = [
|
|
610
|
+
{ detailType: 'component.change.create', detail: { name: 'comp1', version: '1.0.0' } },
|
|
611
|
+
{ detailType: 'component.change.update', detail: { name: 'comp2', version: '2.0.0' } },
|
|
612
|
+
];
|
|
613
|
+
|
|
614
|
+
const mixedResponse = [
|
|
615
|
+
{
|
|
616
|
+
statusCode: 202,
|
|
617
|
+
event: {
|
|
618
|
+
id: 'event-1',
|
|
619
|
+
source: 'component-service',
|
|
620
|
+
detailType: 'test',
|
|
621
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
622
|
+
detail: {},
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
statusCode: 400,
|
|
627
|
+
event: {
|
|
628
|
+
id: 'event-2',
|
|
629
|
+
source: 'component-service',
|
|
630
|
+
detailType: 'test',
|
|
631
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
632
|
+
detail: {},
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
];
|
|
636
|
+
|
|
637
|
+
mockFetch.mockResolvedValueOnce({
|
|
638
|
+
ok: true,
|
|
639
|
+
json: jest.fn().mockResolvedValue(mixedResponse),
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
await expect(eventBusService.publishEventsSafely(events)).rejects.toThrow(
|
|
643
|
+
`Event publishing failed: received non-valid status codes [400] for batch of ${events.length} events`,
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('should log error when network error occurs during batch publishing', async () => {
|
|
650
|
+
const events = [{ detailType: 'component.change.create', detail: { name: 'comp1', version: '1.0.0' } }];
|
|
651
|
+
|
|
652
|
+
const networkError = new Error('Network timeout');
|
|
653
|
+
mockFetch.mockRejectedValueOnce(networkError);
|
|
654
|
+
|
|
655
|
+
await expect(eventBusService.publishEventsSafely(events)).rejects.toThrow(
|
|
656
|
+
`Failed to publish batch of ${events.length} events to event bus: Network timeout`,
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('should throw error when tenantId is not set', async () => {
|
|
663
|
+
const configWithoutTenantId = { ...mockConfig, tenantId: '' };
|
|
664
|
+
const serviceWithoutTenantId = new EventBusService(mockLogger as any, configWithoutTenantId);
|
|
665
|
+
|
|
666
|
+
const events = [{ detailType: 'test.event', detail: { test: 'data' } }];
|
|
667
|
+
|
|
668
|
+
await expect(serviceWithoutTenantId.publishEventsSafely(events)).rejects.toThrow(
|
|
669
|
+
/Failed to publish batch of 1 events to event bus: Tenant ID is not set/,
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('should correctly set tenant ID before publishing', async () => {
|
|
676
|
+
const events = [{ detailType: 'component.change.create', detail: { name: 'comp1', version: '1.0.0' } }];
|
|
677
|
+
|
|
678
|
+
// Create service without tenant ID initially
|
|
679
|
+
const configWithoutTenantId = { ...mockConfig, tenantId: '' };
|
|
680
|
+
const service = new EventBusService(mockLogger as any, configWithoutTenantId);
|
|
681
|
+
|
|
682
|
+
// Set tenant ID
|
|
683
|
+
service.setTenantId('new-tenant');
|
|
684
|
+
|
|
685
|
+
mockFetch.mockResolvedValueOnce({
|
|
686
|
+
ok: true,
|
|
687
|
+
json: jest.fn().mockResolvedValue([
|
|
688
|
+
{
|
|
689
|
+
statusCode: 202,
|
|
690
|
+
event: {
|
|
691
|
+
id: 'event-1',
|
|
692
|
+
source: 'component-service',
|
|
693
|
+
detailType: 'test',
|
|
694
|
+
time: '2023-01-01T00:00:05.000Z',
|
|
695
|
+
detail: {},
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
]),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await expect(service.publishEventsSafely(events)).resolves.not.toThrow();
|
|
702
|
+
|
|
703
|
+
const callArgs = mockFetch.mock.calls[0];
|
|
704
|
+
expect(callArgs[1].headers['x-dxp-tenant']).toBe('new-tenant');
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
});
|