@queuedash/sdk 0.1.0 → 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,809 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const client_1 = require("./client");
4
+ // Increase max listeners to avoid warnings from multiple SDK instances
5
+ process.setMaxListeners(100);
6
+ // Mock timers for testing
7
+ jest.useFakeTimers();
8
+ describe("Queuedash SDK", () => {
9
+ let sdk;
10
+ let mockFetch;
11
+ let originalFetch;
12
+ let consoleWarnSpy;
13
+ let consoleErrorSpy;
14
+ let consoleLogSpy;
15
+ beforeEach(() => {
16
+ // Store original fetch
17
+ originalFetch = global.fetch;
18
+ // Mock fetch
19
+ mockFetch = jest.fn();
20
+ global.fetch = mockFetch;
21
+ // Spy on console methods
22
+ consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
23
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
24
+ consoleLogSpy = jest.spyOn(console, "log").mockImplementation();
25
+ });
26
+ afterEach(async () => {
27
+ // Restore console methods first
28
+ consoleWarnSpy.mockRestore();
29
+ consoleErrorSpy.mockRestore();
30
+ consoleLogSpy.mockRestore();
31
+ // Clean up SDK if it exists
32
+ if (sdk) {
33
+ try {
34
+ // Temporarily restore real timers for cleanup
35
+ jest.useRealTimers();
36
+ // Add timeout protection for stop()
37
+ await Promise.race([
38
+ sdk.stop(),
39
+ new Promise((resolve) => setTimeout(resolve, 1000)),
40
+ ]);
41
+ }
42
+ catch {
43
+ // Ignore cleanup errors
44
+ }
45
+ jest.useFakeTimers();
46
+ }
47
+ // Restore original fetch
48
+ global.fetch = originalFetch;
49
+ // Clear all timers
50
+ jest.clearAllTimers();
51
+ });
52
+ describe("Constructor & Configuration", () => {
53
+ it("should throw error when apiKey is missing", () => {
54
+ expect(() => new client_1.Queuedash({})).toThrow("Queuedash: apiKey is required");
55
+ });
56
+ it("should throw error when apiKey is empty string", () => {
57
+ expect(() => new client_1.Queuedash({ apiKey: "" })).toThrow("Queuedash: apiKey is required");
58
+ });
59
+ it("should initialize with required apiKey", () => {
60
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
61
+ expect(sdk).toBeInstanceOf(client_1.Queuedash);
62
+ });
63
+ it("should use default values when not provided", () => {
64
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
65
+ // We can't directly access private properties, but we can verify behavior
66
+ // by checking that flush is called with default batch size
67
+ mockFetch.mockResolvedValue({
68
+ ok: true,
69
+ json: () => Promise.resolve({ success: true, synced: 50 }),
70
+ });
71
+ // Add 50 jobs (default batch size) to trigger flush
72
+ for (let i = 0; i < 50; i++) {
73
+ sdk.syncJob(createMockJobData(`job-${i}`));
74
+ }
75
+ expect(mockFetch).toHaveBeenCalled();
76
+ });
77
+ it("should use production URL in production environment", () => {
78
+ const originalEnv = process.env.NODE_ENV;
79
+ process.env.NODE_ENV = "production";
80
+ mockFetch.mockResolvedValue({
81
+ ok: true,
82
+ json: () => Promise.resolve({ success: true, synced: 1 }),
83
+ });
84
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
85
+ sdk.syncJob(createMockJobData("job-1"));
86
+ // Trigger flush by advancing timer
87
+ jest.advanceTimersByTime(100);
88
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("https://api.queuedash.com"), expect.any(Object));
89
+ process.env.NODE_ENV = originalEnv;
90
+ });
91
+ it("should use localhost URL in non-production environment", () => {
92
+ const originalEnv = process.env.NODE_ENV;
93
+ process.env.NODE_ENV = "development";
94
+ mockFetch.mockResolvedValue({
95
+ ok: true,
96
+ json: () => Promise.resolve({ success: true, synced: 1 }),
97
+ });
98
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
99
+ sdk.syncJob(createMockJobData("job-1"));
100
+ jest.advanceTimersByTime(100);
101
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("http://localhost:4001"), expect.any(Object));
102
+ process.env.NODE_ENV = originalEnv;
103
+ });
104
+ it("should override default URL with baseUrl option", () => {
105
+ mockFetch.mockResolvedValue({
106
+ ok: true,
107
+ json: () => Promise.resolve({ success: true, synced: 1 }),
108
+ });
109
+ sdk = new client_1.Queuedash({
110
+ apiKey: "test-api-key",
111
+ baseUrl: "https://custom.api.com",
112
+ });
113
+ sdk.syncJob(createMockJobData("job-1"));
114
+ jest.advanceTimersByTime(100);
115
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("https://custom.api.com"), expect.any(Object));
116
+ });
117
+ it("should use custom batchSize", () => {
118
+ mockFetch.mockResolvedValue({
119
+ ok: true,
120
+ json: () => Promise.resolve({ success: true, synced: 10 }),
121
+ });
122
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", batchSize: 10 });
123
+ // Add 9 jobs - should not trigger flush
124
+ for (let i = 0; i < 9; i++) {
125
+ sdk.syncJob(createMockJobData(`job-${i}`));
126
+ }
127
+ expect(mockFetch).not.toHaveBeenCalled();
128
+ // Add 10th job - should trigger flush
129
+ sdk.syncJob(createMockJobData("job-9"));
130
+ expect(mockFetch).toHaveBeenCalled();
131
+ });
132
+ it("should use custom flushInterval", () => {
133
+ mockFetch.mockResolvedValue({
134
+ ok: true,
135
+ json: () => Promise.resolve({ success: true, synced: 1 }),
136
+ });
137
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", flushInterval: 500 });
138
+ sdk.syncJob(createMockJobData("job-1"));
139
+ // Should not flush at 100ms (default)
140
+ jest.advanceTimersByTime(100);
141
+ expect(mockFetch).not.toHaveBeenCalled();
142
+ // Should flush at 500ms (custom)
143
+ jest.advanceTimersByTime(400);
144
+ expect(mockFetch).toHaveBeenCalled();
145
+ });
146
+ it("should call custom onError handler on error", async () => {
147
+ const onError = jest.fn();
148
+ mockFetch.mockRejectedValue(new Error("Network error"));
149
+ sdk = new client_1.Queuedash({
150
+ apiKey: "test-api-key",
151
+ maxRetries: 0,
152
+ onError,
153
+ });
154
+ sdk.syncJob(createMockJobData("job-1"));
155
+ // Use real timers for async operations
156
+ jest.useRealTimers();
157
+ await sdk.flush();
158
+ jest.useFakeTimers();
159
+ expect(onError).toHaveBeenCalled();
160
+ expect(onError.mock.calls[0][0].message).toContain("Network error");
161
+ });
162
+ });
163
+ describe("Queue Type Detection", () => {
164
+ beforeEach(() => {
165
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
166
+ });
167
+ it("should detect BullMQ Queue (has getJob and opts.connection)", () => {
168
+ const mockBullMQQueue = {
169
+ name: "test-queue",
170
+ getJob: jest.fn(),
171
+ opts: { connection: {} },
172
+ redisPrefix: "bull",
173
+ };
174
+ // BullMQ detection works - attach returns this for chaining
175
+ // The internal async call to create QueueEvents will fail (no bullmq installed)
176
+ // but detection itself should not throw "Unknown queue type"
177
+ const result = sdk.attach(mockBullMQQueue);
178
+ expect(result).toBe(sdk);
179
+ // The error will be thrown asynchronously but detection worked
180
+ // because it didn't throw "Unknown queue type" synchronously
181
+ });
182
+ it("should detect BullMQ Worker (has opts.connection, no getJob)", () => {
183
+ const mockBullMQWorker = {
184
+ name: "test-queue",
185
+ opts: { connection: {} },
186
+ };
187
+ // Worker monitoring shows warning
188
+ sdk.attach(mockBullMQWorker);
189
+ expect(consoleWarnSpy).toHaveBeenCalledWith("Queuedash: Worker monitoring coming soon");
190
+ });
191
+ it("should detect Bull queue (has clients array)", () => {
192
+ const mockBullQueue = {
193
+ name: "test-queue",
194
+ clients: [{}],
195
+ on: jest.fn(),
196
+ };
197
+ sdk.attach(mockBullQueue);
198
+ expect(mockBullQueue.on).toHaveBeenCalled();
199
+ });
200
+ it("should detect Bee-Queue (has settings property)", () => {
201
+ const mockBeeQueue = {
202
+ name: "test-queue",
203
+ settings: {},
204
+ on: jest.fn(),
205
+ };
206
+ sdk.attach(mockBeeQueue);
207
+ expect(mockBeeQueue.on).toHaveBeenCalled();
208
+ });
209
+ it("should throw for unknown queue type", () => {
210
+ const unknownQueue = { name: "test-queue" };
211
+ expect(() => sdk.attach(unknownQueue)).toThrow("Queuedash: Unknown queue type");
212
+ });
213
+ it("should throw for null/undefined resource", () => {
214
+ expect(() => sdk.attach(null)).toThrow("Queuedash: Unknown queue type");
215
+ expect(() => sdk.attach(undefined)).toThrow("Queuedash: Unknown queue type");
216
+ });
217
+ it("should throw for non-object resource", () => {
218
+ expect(() => sdk.attach("not-a-queue")).toThrow("Queuedash: Unknown queue type");
219
+ expect(() => sdk.attach(123)).toThrow("Queuedash: Unknown queue type");
220
+ });
221
+ });
222
+ describe("attach() Method", () => {
223
+ beforeEach(() => {
224
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
225
+ });
226
+ it("should support method chaining", () => {
227
+ const mockBullQueue = {
228
+ name: "test-queue",
229
+ clients: [{}],
230
+ on: jest.fn(),
231
+ };
232
+ const result = sdk.attach(mockBullQueue);
233
+ expect(result).toBe(sdk);
234
+ });
235
+ it("should prevent duplicate Bull queue attachments", () => {
236
+ const mockBullQueue = {
237
+ name: "test-queue",
238
+ clients: [{}],
239
+ on: jest.fn(),
240
+ };
241
+ sdk.attach(mockBullQueue);
242
+ sdk.attach(mockBullQueue);
243
+ expect(consoleWarnSpy).toHaveBeenCalledWith('Queuedash: Queue "test-queue" is already attached');
244
+ });
245
+ it("should prevent duplicate Bee-Queue attachments", () => {
246
+ const mockBeeQueue = {
247
+ name: "test-queue",
248
+ settings: {},
249
+ on: jest.fn(),
250
+ };
251
+ sdk.attach(mockBeeQueue);
252
+ sdk.attach(mockBeeQueue);
253
+ expect(consoleWarnSpy).toHaveBeenCalledWith('Queuedash: Queue "test-queue" is already attached');
254
+ });
255
+ it("should attach Bull queue and listen to events", () => {
256
+ const mockBullQueue = {
257
+ name: "test-queue",
258
+ clients: [{}],
259
+ on: jest.fn(),
260
+ };
261
+ sdk.attach(mockBullQueue);
262
+ expect(mockBullQueue.on).toHaveBeenCalledWith("global:completed", expect.any(Function));
263
+ expect(mockBullQueue.on).toHaveBeenCalledWith("global:failed", expect.any(Function));
264
+ expect(mockBullQueue.on).toHaveBeenCalledWith("active", expect.any(Function));
265
+ expect(mockBullQueue.on).toHaveBeenCalledWith("progress", expect.any(Function));
266
+ expect(mockBullQueue.on).toHaveBeenCalledWith("removed", expect.any(Function));
267
+ });
268
+ it("should attach Bee-Queue and listen to events", () => {
269
+ const mockBeeQueue = {
270
+ name: "test-queue",
271
+ settings: {},
272
+ on: jest.fn(),
273
+ };
274
+ sdk.attach(mockBeeQueue);
275
+ expect(mockBeeQueue.on).toHaveBeenCalledWith("succeeded", expect.any(Function));
276
+ expect(mockBeeQueue.on).toHaveBeenCalledWith("failed", expect.any(Function));
277
+ expect(mockBeeQueue.on).toHaveBeenCalledWith("retrying", expect.any(Function));
278
+ });
279
+ });
280
+ describe("syncJob() Method", () => {
281
+ beforeEach(() => {
282
+ mockFetch.mockResolvedValue({
283
+ ok: true,
284
+ json: () => Promise.resolve({ success: true, synced: 1 }),
285
+ });
286
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", batchSize: 5 });
287
+ });
288
+ it("should queue job for sync", () => {
289
+ sdk.syncJob(createMockJobData("job-1"));
290
+ // Job should be queued, not flushed yet
291
+ expect(mockFetch).not.toHaveBeenCalled();
292
+ });
293
+ it("should trigger flush when batch size is reached", () => {
294
+ for (let i = 0; i < 5; i++) {
295
+ sdk.syncJob(createMockJobData(`job-${i}`));
296
+ }
297
+ expect(mockFetch).toHaveBeenCalled();
298
+ });
299
+ it("should deduplicate jobs with same queueName:jobId", () => {
300
+ const job = createMockJobData("job-1");
301
+ sdk.syncJob(job);
302
+ sdk.syncJob(job);
303
+ sdk.syncJob(job);
304
+ // Trigger flush
305
+ jest.advanceTimersByTime(100);
306
+ // Should only sync once
307
+ expect(mockFetch).toHaveBeenCalledTimes(1);
308
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
309
+ expect(body.jobs).toHaveLength(1);
310
+ });
311
+ it("should respect maxQueueSize and drop oldest jobs", () => {
312
+ sdk = new client_1.Queuedash({
313
+ apiKey: "test-api-key",
314
+ batchSize: 100,
315
+ maxQueueSize: 3,
316
+ });
317
+ sdk.syncJob(createMockJobData("job-1"));
318
+ sdk.syncJob(createMockJobData("job-2"));
319
+ sdk.syncJob(createMockJobData("job-3"));
320
+ sdk.syncJob(createMockJobData("job-4")); // Should drop job-1
321
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Queue size limit reached"));
322
+ });
323
+ it("should not sync when circuit breaker is open", async () => {
324
+ // Open circuit breaker by simulating 10 failures
325
+ mockFetch.mockRejectedValue(new Error("Network error"));
326
+ for (let i = 0; i < 10; i++) {
327
+ sdk.syncJob(createMockJobData(`job-${i}`));
328
+ jest.useRealTimers();
329
+ await sdk.flush();
330
+ jest.useFakeTimers();
331
+ }
332
+ // Reset mock
333
+ mockFetch.mockClear();
334
+ mockFetch.mockResolvedValue({
335
+ ok: true,
336
+ json: () => Promise.resolve({ success: true, synced: 1 }),
337
+ });
338
+ // This should be ignored due to circuit breaker
339
+ sdk.syncJob(createMockJobData("job-after-circuit-open"));
340
+ expect(mockFetch).not.toHaveBeenCalled();
341
+ });
342
+ });
343
+ describe("flush() Method", () => {
344
+ beforeEach(() => {
345
+ mockFetch.mockResolvedValue({
346
+ ok: true,
347
+ json: () => Promise.resolve({ success: true, synced: 1 }),
348
+ });
349
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
350
+ });
351
+ it("should make POST request to correct endpoint", async () => {
352
+ sdk.syncJob(createMockJobData("job-1"));
353
+ jest.useRealTimers();
354
+ await sdk.flush();
355
+ jest.useFakeTimers();
356
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/api/v1/jobs/sync"), expect.objectContaining({
357
+ method: "POST",
358
+ }));
359
+ });
360
+ it("should include Authorization header", async () => {
361
+ sdk.syncJob(createMockJobData("job-1"));
362
+ jest.useRealTimers();
363
+ await sdk.flush();
364
+ jest.useFakeTimers();
365
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
366
+ headers: expect.objectContaining({
367
+ Authorization: "Bearer test-api-key",
368
+ }),
369
+ }));
370
+ });
371
+ it("should include Content-Type header", async () => {
372
+ sdk.syncJob(createMockJobData("job-1"));
373
+ jest.useRealTimers();
374
+ await sdk.flush();
375
+ jest.useFakeTimers();
376
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
377
+ headers: expect.objectContaining({
378
+ "Content-Type": "application/json",
379
+ }),
380
+ }));
381
+ });
382
+ it("should send jobs in request body", async () => {
383
+ sdk.syncJob(createMockJobData("job-1"));
384
+ jest.useRealTimers();
385
+ await sdk.flush();
386
+ jest.useFakeTimers();
387
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
388
+ expect(body.jobs).toBeDefined();
389
+ expect(body.jobs).toHaveLength(1);
390
+ expect(body.jobs[0].jobId).toBe("job-1");
391
+ });
392
+ it("should clear jobs from queue after successful sync", async () => {
393
+ sdk.syncJob(createMockJobData("job-1"));
394
+ jest.useRealTimers();
395
+ await sdk.flush();
396
+ jest.useFakeTimers();
397
+ // Flush again - should not make another request
398
+ jest.useRealTimers();
399
+ await sdk.flush();
400
+ jest.useFakeTimers();
401
+ expect(mockFetch).toHaveBeenCalledTimes(1);
402
+ });
403
+ it("should not flush when queue is empty", async () => {
404
+ jest.useRealTimers();
405
+ await sdk.flush();
406
+ jest.useFakeTimers();
407
+ expect(mockFetch).not.toHaveBeenCalled();
408
+ });
409
+ it("should not flush when already flushing", async () => {
410
+ // Create a slow fetch
411
+ mockFetch.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({
412
+ ok: true,
413
+ json: () => Promise.resolve({ success: true, synced: 1 }),
414
+ }), 100)));
415
+ sdk.syncJob(createMockJobData("job-1"));
416
+ sdk.syncJob(createMockJobData("job-2"));
417
+ jest.useRealTimers();
418
+ // Start two flushes concurrently
419
+ const flush1 = sdk.flush();
420
+ const flush2 = sdk.flush();
421
+ await Promise.all([flush1, flush2]);
422
+ jest.useFakeTimers();
423
+ // Only one fetch should have been made
424
+ expect(mockFetch).toHaveBeenCalledTimes(1);
425
+ });
426
+ it("should warn on partial success", async () => {
427
+ mockFetch.mockResolvedValue({
428
+ ok: true,
429
+ json: () => Promise.resolve({
430
+ success: false,
431
+ synced: 0,
432
+ errors: ["Queue not found"],
433
+ }),
434
+ });
435
+ sdk.syncJob(createMockJobData("job-1"));
436
+ jest.useRealTimers();
437
+ await sdk.flush();
438
+ jest.useFakeTimers();
439
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Some jobs failed to sync"), expect.any(Array));
440
+ });
441
+ it("should retry on failure with exponential backoff", async () => {
442
+ let attempts = 0;
443
+ mockFetch.mockImplementation(() => {
444
+ attempts++;
445
+ if (attempts < 3) {
446
+ return Promise.reject(new Error("Network error"));
447
+ }
448
+ return Promise.resolve({
449
+ ok: true,
450
+ json: () => Promise.resolve({ success: true, synced: 1 }),
451
+ });
452
+ });
453
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", maxRetries: 5 });
454
+ sdk.syncJob(createMockJobData("job-1"));
455
+ jest.useRealTimers();
456
+ await sdk.flush();
457
+ // Wait for retries
458
+ await new Promise((resolve) => setTimeout(resolve, 5000));
459
+ jest.useFakeTimers();
460
+ expect(attempts).toBeGreaterThanOrEqual(3);
461
+ });
462
+ it("should handle HTTP error responses", async () => {
463
+ const onError = jest.fn();
464
+ mockFetch.mockResolvedValue({
465
+ ok: false,
466
+ status: 401,
467
+ text: () => Promise.resolve("Unauthorized"),
468
+ });
469
+ sdk = new client_1.Queuedash({
470
+ apiKey: "invalid-key",
471
+ maxRetries: 0,
472
+ onError,
473
+ });
474
+ sdk.syncJob(createMockJobData("job-1"));
475
+ jest.useRealTimers();
476
+ await sdk.flush();
477
+ jest.useFakeTimers();
478
+ expect(onError).toHaveBeenCalled();
479
+ expect(onError.mock.calls[0][0].message).toContain("401");
480
+ });
481
+ });
482
+ describe("deleteJob() Method", () => {
483
+ beforeEach(() => {
484
+ mockFetch.mockResolvedValue({
485
+ ok: true,
486
+ json: () => Promise.resolve({ success: true }),
487
+ });
488
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
489
+ });
490
+ it("should make DELETE request to correct endpoint", async () => {
491
+ jest.useRealTimers();
492
+ await sdk.deleteJob("job-123", "test-queue");
493
+ jest.useFakeTimers();
494
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/api/v1/jobs/job-123"), expect.objectContaining({
495
+ method: "DELETE",
496
+ }));
497
+ });
498
+ it("should include queue name in query string", async () => {
499
+ jest.useRealTimers();
500
+ await sdk.deleteJob("job-123", "test-queue");
501
+ jest.useFakeTimers();
502
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("queueName=test-queue"), expect.any(Object));
503
+ });
504
+ it("should URL encode job ID and queue name", async () => {
505
+ jest.useRealTimers();
506
+ await sdk.deleteJob("job/with/slashes", "queue with spaces");
507
+ jest.useFakeTimers();
508
+ const url = mockFetch.mock.calls[0][0];
509
+ expect(url).toContain("job%2Fwith%2Fslashes");
510
+ expect(url).toContain("queue%20with%20spaces");
511
+ });
512
+ it("should include Authorization header", async () => {
513
+ jest.useRealTimers();
514
+ await sdk.deleteJob("job-123", "test-queue");
515
+ jest.useFakeTimers();
516
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
517
+ headers: expect.objectContaining({
518
+ Authorization: "Bearer test-api-key",
519
+ }),
520
+ }));
521
+ });
522
+ it("should call onError on failure", async () => {
523
+ const onError = jest.fn();
524
+ mockFetch.mockRejectedValue(new Error("Network error"));
525
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
526
+ jest.useRealTimers();
527
+ await sdk.deleteJob("job-123", "test-queue");
528
+ jest.useFakeTimers();
529
+ expect(onError).toHaveBeenCalled();
530
+ expect(onError.mock.calls[0][0].message).toContain("Failed to delete job");
531
+ });
532
+ it("should log error when no onError handler", async () => {
533
+ mockFetch.mockRejectedValue(new Error("Network error"));
534
+ jest.useRealTimers();
535
+ await sdk.deleteJob("job-123", "test-queue");
536
+ jest.useFakeTimers();
537
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to delete job"), expect.any(Error));
538
+ });
539
+ });
540
+ describe("Circuit Breaker", () => {
541
+ beforeEach(() => {
542
+ mockFetch.mockRejectedValue(new Error("Network error"));
543
+ sdk = new client_1.Queuedash({
544
+ apiKey: "test-api-key",
545
+ maxRetries: 0, // No retries for faster testing
546
+ });
547
+ });
548
+ it("should open after 10 consecutive failures", async () => {
549
+ jest.useRealTimers();
550
+ for (let i = 0; i < 10; i++) {
551
+ sdk.syncJob(createMockJobData(`job-${i}`));
552
+ await sdk.flush();
553
+ }
554
+ jest.useFakeTimers();
555
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Circuit breaker opened"));
556
+ });
557
+ it("should stop syncing when open", async () => {
558
+ jest.useRealTimers();
559
+ // Open circuit breaker
560
+ for (let i = 0; i < 10; i++) {
561
+ sdk.syncJob(createMockJobData(`job-${i}`));
562
+ await sdk.flush();
563
+ }
564
+ // Clear mock
565
+ mockFetch.mockClear();
566
+ // This should not make a request
567
+ sdk.syncJob(createMockJobData("job-after-open"));
568
+ await sdk.flush();
569
+ jest.useFakeTimers();
570
+ expect(mockFetch).not.toHaveBeenCalled();
571
+ });
572
+ it("should auto-reset after 60 seconds", async () => {
573
+ // Create SDK with fake timers active so circuit breaker timer uses fake timers
574
+ sdk = new client_1.Queuedash({
575
+ apiKey: "test-api-key",
576
+ maxRetries: 0,
577
+ });
578
+ // Open circuit breaker with fake timers
579
+ for (let i = 0; i < 10; i++) {
580
+ sdk.syncJob(createMockJobData(`job-${i}`));
581
+ // Manually trigger the flush promise
582
+ const flushPromise = sdk.flush();
583
+ // Run the promise to completion
584
+ await Promise.resolve();
585
+ try {
586
+ await flushPromise;
587
+ }
588
+ catch {
589
+ // Expected to fail
590
+ }
591
+ }
592
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Circuit breaker opened"));
593
+ // Advance time by 60 seconds
594
+ jest.advanceTimersByTime(60000);
595
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Circuit breaker reset"));
596
+ });
597
+ it("should reset failure counter on successful sync", async () => {
598
+ let attempts = 0;
599
+ mockFetch.mockImplementation(() => {
600
+ attempts++;
601
+ if (attempts <= 5) {
602
+ return Promise.reject(new Error("Network error"));
603
+ }
604
+ return Promise.resolve({
605
+ ok: true,
606
+ json: () => Promise.resolve({ success: true, synced: 1 }),
607
+ });
608
+ });
609
+ sdk = new client_1.Queuedash({
610
+ apiKey: "test-api-key",
611
+ maxRetries: 0,
612
+ });
613
+ jest.useRealTimers();
614
+ // 5 failures
615
+ for (let i = 0; i < 5; i++) {
616
+ sdk.syncJob(createMockJobData(`job-${i}`));
617
+ await sdk.flush();
618
+ }
619
+ // 1 success - should reset counter
620
+ sdk.syncJob(createMockJobData("job-success"));
621
+ await sdk.flush();
622
+ // 5 more failures - should not open circuit breaker
623
+ for (let i = 0; i < 5; i++) {
624
+ sdk.syncJob(createMockJobData(`job-after-${i}`));
625
+ await sdk.flush();
626
+ }
627
+ jest.useFakeTimers();
628
+ // Circuit breaker should NOT be open
629
+ expect(consoleErrorSpy).not.toHaveBeenCalledWith(expect.stringContaining("Circuit breaker opened"));
630
+ });
631
+ });
632
+ describe("Job Data Extraction", () => {
633
+ beforeEach(() => {
634
+ mockFetch.mockResolvedValue({
635
+ ok: true,
636
+ json: () => Promise.resolve({ success: true, synced: 1 }),
637
+ });
638
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
639
+ });
640
+ it("should handle Bull job events and extract data", () => {
641
+ const mockBullQueue = {
642
+ name: "test-queue",
643
+ clients: [{}],
644
+ on: jest.fn(),
645
+ };
646
+ sdk.attach(mockBullQueue);
647
+ // Get the active handler
648
+ const activeHandler = mockBullQueue.on.mock.calls.find((call) => call[0] === "active")[1];
649
+ // Simulate job event
650
+ const mockJob = {
651
+ id: "123",
652
+ name: "test-job",
653
+ data: { foo: "bar" },
654
+ opts: { priority: 1 },
655
+ timestamp: Date.now(),
656
+ processedOn: Date.now(),
657
+ finishedOn: null,
658
+ failedReason: null,
659
+ stacktrace: [],
660
+ progress: () => 50,
661
+ };
662
+ activeHandler(mockJob);
663
+ // Advance timer to trigger flush
664
+ jest.advanceTimersByTime(100);
665
+ expect(mockFetch).toHaveBeenCalled();
666
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
667
+ expect(body.jobs[0].jobId).toBe("123");
668
+ expect(body.jobs[0].name).toBe("test-job");
669
+ expect(body.jobs[0].data).toEqual({ foo: "bar" });
670
+ expect(body.jobs[0].progress).toBe(50);
671
+ });
672
+ it("should handle Bee-Queue succeeded event and set finishedAt", () => {
673
+ const mockBeeQueue = {
674
+ name: "test-queue",
675
+ settings: {},
676
+ on: jest.fn(),
677
+ };
678
+ sdk.attach(mockBeeQueue);
679
+ // Get the succeeded handler
680
+ const succeededHandler = mockBeeQueue.on.mock.calls.find((call) => call[0] === "succeeded")[1];
681
+ const mockJob = {
682
+ id: 123,
683
+ data: { foo: "bar" },
684
+ options: { timestamp: Date.now() },
685
+ };
686
+ succeededHandler(mockJob);
687
+ jest.advanceTimersByTime(100);
688
+ expect(mockFetch).toHaveBeenCalled();
689
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
690
+ expect(body.jobs[0].jobId).toBe("123");
691
+ expect(body.jobs[0].finishedAt).toBeDefined();
692
+ });
693
+ it("should handle Bee-Queue failed event with error info", () => {
694
+ const mockBeeQueue = {
695
+ name: "test-queue",
696
+ settings: {},
697
+ on: jest.fn(),
698
+ };
699
+ sdk.attach(mockBeeQueue);
700
+ // Get the failed handler
701
+ const failedHandler = mockBeeQueue.on.mock.calls.find((call) => call[0] === "failed")[1];
702
+ const mockJob = {
703
+ id: 456,
704
+ data: { foo: "bar" },
705
+ options: {},
706
+ };
707
+ const error = new Error("Job processing failed");
708
+ error.stack = "Error: Job processing failed\n at test.js:1:1";
709
+ failedHandler(mockJob, error);
710
+ jest.advanceTimersByTime(100);
711
+ expect(mockFetch).toHaveBeenCalled();
712
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
713
+ expect(body.jobs[0].failedReason).toBe("Job processing failed");
714
+ expect(body.jobs[0].stacktrace).toHaveLength(1);
715
+ });
716
+ it("should handle Bee-Queue retrying event", () => {
717
+ const mockBeeQueue = {
718
+ name: "test-queue",
719
+ settings: {},
720
+ on: jest.fn(),
721
+ };
722
+ sdk.attach(mockBeeQueue);
723
+ // Get the retrying handler
724
+ const retryingHandler = mockBeeQueue.on.mock.calls.find((call) => call[0] === "retrying")[1];
725
+ const mockJob = {
726
+ id: 789,
727
+ data: { foo: "bar" },
728
+ options: {},
729
+ };
730
+ retryingHandler(mockJob);
731
+ jest.advanceTimersByTime(100);
732
+ expect(mockFetch).toHaveBeenCalled();
733
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
734
+ expect(body.jobs[0].retriedAt).toBeDefined();
735
+ });
736
+ });
737
+ describe("Graceful Shutdown", () => {
738
+ beforeEach(() => {
739
+ mockFetch.mockResolvedValue({
740
+ ok: true,
741
+ json: () => Promise.resolve({ success: true, synced: 1 }),
742
+ });
743
+ sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
744
+ });
745
+ it("should stop flush timer on stop()", async () => {
746
+ sdk.syncJob(createMockJobData("job-1"));
747
+ jest.useRealTimers();
748
+ await sdk.stop();
749
+ jest.useFakeTimers();
750
+ // The job should have been flushed
751
+ expect(mockFetch).toHaveBeenCalled();
752
+ });
753
+ it("should flush remaining jobs on stop()", async () => {
754
+ sdk.syncJob(createMockJobData("job-1"));
755
+ sdk.syncJob(createMockJobData("job-2"));
756
+ // Don't wait for timer, call stop directly
757
+ jest.useRealTimers();
758
+ await sdk.stop();
759
+ jest.useFakeTimers();
760
+ expect(mockFetch).toHaveBeenCalled();
761
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
762
+ expect(body.jobs).toHaveLength(2);
763
+ });
764
+ it("should close managed QueueEvents on stop()", async () => {
765
+ // We can't easily test this without real BullMQ, but we can ensure stop() doesn't throw
766
+ jest.useRealTimers();
767
+ await expect(sdk.stop()).resolves.not.toThrow();
768
+ jest.useFakeTimers();
769
+ });
770
+ });
771
+ describe("Periodic Flush", () => {
772
+ beforeEach(() => {
773
+ mockFetch.mockResolvedValue({
774
+ ok: true,
775
+ json: () => Promise.resolve({ success: true, synced: 1 }),
776
+ });
777
+ sdk = new client_1.Queuedash({
778
+ apiKey: "test-api-key",
779
+ flushInterval: 100,
780
+ batchSize: 1000, // High batch size so it doesn't auto-flush
781
+ });
782
+ });
783
+ it("should flush periodically based on flushInterval", () => {
784
+ sdk.syncJob(createMockJobData("job-1"));
785
+ expect(mockFetch).not.toHaveBeenCalled();
786
+ // Advance time by flush interval
787
+ jest.advanceTimersByTime(100);
788
+ expect(mockFetch).toHaveBeenCalled();
789
+ });
790
+ it("should not flush when queue is empty during periodic flush", () => {
791
+ // Advance time without adding any jobs
792
+ jest.advanceTimersByTime(100);
793
+ expect(mockFetch).not.toHaveBeenCalled();
794
+ });
795
+ });
796
+ });
797
+ // Helper function to create mock job data
798
+ function createMockJobData(jobId, queueName = "test-queue") {
799
+ return {
800
+ jobId,
801
+ name: "test-job",
802
+ queueName,
803
+ data: { test: true },
804
+ opts: {},
805
+ addedAt: new Date(),
806
+ processedAt: null,
807
+ finishedAt: null,
808
+ };
809
+ }