@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.
- package/README.md +54 -48
- package/dist/client.d.ts +46 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +316 -46
- package/dist/client.spec.d.ts +2 -0
- package/dist/client.spec.d.ts.map +1 -0
- package/dist/client.spec.js +809 -0
- package/dist/index.d.ts +2 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -8
- package/dist/types.d.ts +10 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +22 -7
- package/dist/integrations/bee.d.ts +0 -8
- package/dist/integrations/bee.d.ts.map +0 -1
- package/dist/integrations/bee.js +0 -43
- package/dist/integrations/bull.d.ts +0 -8
- package/dist/integrations/bull.d.ts.map +0 -1
- package/dist/integrations/bull.js +0 -45
- package/dist/integrations/bullmq.d.ts +0 -8
- package/dist/integrations/bullmq.d.ts.map +0 -1
- package/dist/integrations/bullmq.js +0 -46
|
@@ -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
|
+
}
|