opticedge-cloud-utils 1.1.19 → 1.1.20
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/dist/src/number.d.ts +1 -0
- package/dist/src/number.js +6 -0
- package/dist/tests/auth.test.d.ts +1 -0
- package/dist/tests/auth.test.js +79 -0
- package/dist/tests/chunk.test.d.ts +1 -0
- package/dist/tests/chunk.test.js +45 -0
- package/dist/tests/db/mongo.test.d.ts +1 -0
- package/dist/tests/db/mongo.test.js +43 -0
- package/dist/tests/db/mongo2.test.d.ts +1 -0
- package/dist/tests/db/mongo2.test.js +49 -0
- package/dist/tests/db/mongo3.test.d.ts +1 -0
- package/dist/tests/db/mongo3.test.js +60 -0
- package/dist/tests/env.test.d.ts +1 -0
- package/dist/tests/env.test.js +17 -0
- package/dist/tests/number.test.d.ts +1 -0
- package/dist/tests/number.test.js +30 -0
- package/dist/tests/parser.test.d.ts +1 -0
- package/dist/tests/parser.test.js +24 -0
- package/dist/tests/pub.test.d.ts +1 -0
- package/dist/tests/pub.test.js +102 -0
- package/dist/tests/regex.test.d.ts +1 -0
- package/dist/tests/regex.test.js +60 -0
- package/dist/tests/retry.test.d.ts +1 -0
- package/dist/tests/retry.test.js +339 -0
- package/dist/tests/secrets.test.d.ts +1 -0
- package/dist/tests/secrets.test.js +38 -0
- package/dist/tests/task.test.d.ts +1 -0
- package/dist/tests/task.test.js +262 -0
- package/dist/tests/tw/utils.test.d.ts +1 -0
- package/dist/tests/tw/utils.test.js +26 -0
- package/dist/tests/tw/wallet.test.d.ts +1 -0
- package/dist/tests/tw/wallet.test.js +108 -0
- package/dist/tests/validator.d.ts +1 -0
- package/dist/tests/validator.js +34 -0
- package/package.json +1 -1
- package/src/number.ts +3 -0
- package/tests/number.test.ts +34 -0
- package/tsconfig.json +1 -1
- /package/dist/{auth.d.ts → src/auth.d.ts} +0 -0
- /package/dist/{auth.js → src/auth.js} +0 -0
- /package/dist/{chunk.d.ts → src/chunk.d.ts} +0 -0
- /package/dist/{chunk.js → src/chunk.js} +0 -0
- /package/dist/{db → src/db}/mongo.d.ts +0 -0
- /package/dist/{db → src/db}/mongo.js +0 -0
- /package/dist/{db → src/db}/mongo2.d.ts +0 -0
- /package/dist/{db → src/db}/mongo2.js +0 -0
- /package/dist/{db → src/db}/mongo3.d.ts +0 -0
- /package/dist/{db → src/db}/mongo3.js +0 -0
- /package/dist/{env.d.ts → src/env.d.ts} +0 -0
- /package/dist/{env.js → src/env.js} +0 -0
- /package/dist/{index.d.ts → src/index.d.ts} +0 -0
- /package/dist/{index.js → src/index.js} +0 -0
- /package/dist/{parser.d.ts → src/parser.d.ts} +0 -0
- /package/dist/{parser.js → src/parser.js} +0 -0
- /package/dist/{pub.d.ts → src/pub.d.ts} +0 -0
- /package/dist/{pub.js → src/pub.js} +0 -0
- /package/dist/{regex.d.ts → src/regex.d.ts} +0 -0
- /package/dist/{regex.js → src/regex.js} +0 -0
- /package/dist/{retry.d.ts → src/retry.d.ts} +0 -0
- /package/dist/{retry.js → src/retry.js} +0 -0
- /package/dist/{secrets.d.ts → src/secrets.d.ts} +0 -0
- /package/dist/{secrets.js → src/secrets.js} +0 -0
- /package/dist/{task.d.ts → src/task.d.ts} +0 -0
- /package/dist/{task.js → src/task.js} +0 -0
- /package/dist/{tw → src/tw}/utils.d.ts +0 -0
- /package/dist/{tw → src/tw}/utils.js +0 -0
- /package/dist/{tw → src/tw}/wallet.d.ts +0 -0
- /package/dist/{tw → src/tw}/wallet.js +0 -0
- /package/dist/{validator.d.ts → src/validator.d.ts} +0 -0
- /package/dist/{validator.js → src/validator.js} +0 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
// tests/retry.test.ts
|
|
4
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
5
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
6
|
+
};
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const axios_1 = __importDefault(require("axios"));
|
|
9
|
+
const src_1 = require("../src");
|
|
10
|
+
describe('sleep()', () => {
|
|
11
|
+
const realAbortController = global.AbortController;
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
// restore timers and AbortController if changed
|
|
14
|
+
jest.useRealTimers();
|
|
15
|
+
global.AbortController = realAbortController;
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
test('resolves after the specified delay', async () => {
|
|
19
|
+
jest.useFakeTimers();
|
|
20
|
+
const p = (0, src_1.sleep)(100);
|
|
21
|
+
// advance timers by the sleep duration
|
|
22
|
+
jest.advanceTimersByTime(100);
|
|
23
|
+
// allow the Promise microtask queue to run
|
|
24
|
+
await Promise.resolve();
|
|
25
|
+
await expect(p).resolves.toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
test('rejects immediately if signal is already aborted', async () => {
|
|
28
|
+
const ac = new AbortController();
|
|
29
|
+
ac.abort();
|
|
30
|
+
await expect((0, src_1.sleep)(50, ac.signal)).rejects.toThrow('Aborted');
|
|
31
|
+
});
|
|
32
|
+
test('rejects when aborted after scheduling', async () => {
|
|
33
|
+
jest.useFakeTimers();
|
|
34
|
+
const ac = new AbortController();
|
|
35
|
+
const p = (0, src_1.sleep)(1000, ac.signal);
|
|
36
|
+
// abort after the sleep has been scheduled but before timeout
|
|
37
|
+
ac.abort();
|
|
38
|
+
// allow the event-loop microtasks to run
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
await expect(p).rejects.toThrow('Aborted');
|
|
41
|
+
// advancing timers should not resolve the promise
|
|
42
|
+
jest.advanceTimersByTime(1000);
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
await expect(p).rejects.toThrow('Aborted');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('isRetryableDefault', () => {
|
|
48
|
+
test('returns false for falsy errors (covers `if (!err) return false`)', () => {
|
|
49
|
+
expect((0, src_1.isRetryableDefault)(null)).toBe(false);
|
|
50
|
+
expect((0, src_1.isRetryableDefault)(undefined)).toBe(false);
|
|
51
|
+
// Also check a falsy-but-not-nullish value is handled (should coerce to false at top)
|
|
52
|
+
expect((0, src_1.isRetryableDefault)(0)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
test('returns false for an object with no retryable properties', () => {
|
|
55
|
+
expect((0, src_1.isRetryableDefault)({})).toBe(false);
|
|
56
|
+
expect((0, src_1.isRetryableDefault)({ foo: 'bar' })).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
test('matches message containing "timeout" (case-insensitive)', () => {
|
|
59
|
+
expect((0, src_1.isRetryableDefault)(new Error('Request timed out'))).toBe(true);
|
|
60
|
+
expect((0, src_1.isRetryableDefault)(new Error('TIMEOUT occurred'))).toBe(true);
|
|
61
|
+
expect((0, src_1.isRetryableDefault)({ message: 'this is a timeout error' })).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
test('matches message containing "temporary"', () => {
|
|
64
|
+
expect((0, src_1.isRetryableDefault)(new Error('Temporary failure contacting host'))).toBe(true);
|
|
65
|
+
expect((0, src_1.isRetryableDefault)({ message: 'temporary issue' })).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
test('matches message containing "unavailable"', () => {
|
|
68
|
+
expect((0, src_1.isRetryableDefault)(new Error('Service Unavailable'))).toBe(true);
|
|
69
|
+
expect((0, src_1.isRetryableDefault)({ message: 'unavailable resource' })).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
test('matches message containing "econnreset" or "etimedout" (substrings, case-insensitive)', () => {
|
|
72
|
+
expect((0, src_1.isRetryableDefault)(new Error('ECONNRESET by peer'))).toBe(true);
|
|
73
|
+
expect((0, src_1.isRetryableDefault)(new Error('econnreset'))).toBe(true);
|
|
74
|
+
expect((0, src_1.isRetryableDefault)(new Error('ETIMEDOUT waiting for response'))).toBe(true);
|
|
75
|
+
expect((0, src_1.isRetryableDefault)({ message: 'some ETIMEDOUT occurred' })).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
test('does not treat unrelated messages as retryable', () => {
|
|
78
|
+
expect((0, src_1.isRetryableDefault)(new Error('Bad request'))).toBe(false);
|
|
79
|
+
expect((0, src_1.isRetryableDefault)({ message: 'invalid parameter provided' })).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
test('still returns true when message sits alongside other properties (defensive)', () => {
|
|
82
|
+
const errLike = {
|
|
83
|
+
code: undefined,
|
|
84
|
+
response: undefined,
|
|
85
|
+
message: 'temporary network hiccup'
|
|
86
|
+
};
|
|
87
|
+
expect((0, src_1.isRetryableDefault)(errLike)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('isRetryableAxios()', () => {
|
|
91
|
+
// Helper to synthesize axios-like errors
|
|
92
|
+
function makeAxiosError(payload = {}) {
|
|
93
|
+
// minimal axios-style error: flag + response optionally
|
|
94
|
+
const err = new Error(payload.message ?? 'axios-err');
|
|
95
|
+
err.isAxiosError = true;
|
|
96
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'response')) {
|
|
97
|
+
err.response = payload.response;
|
|
98
|
+
}
|
|
99
|
+
else if (payload.response === undefined && payload.noResponse) {
|
|
100
|
+
// explicit network/no-response: leave out response
|
|
101
|
+
}
|
|
102
|
+
if (payload.code)
|
|
103
|
+
err.code = payload.code;
|
|
104
|
+
if (payload.message)
|
|
105
|
+
err.message = payload.message;
|
|
106
|
+
return err;
|
|
107
|
+
}
|
|
108
|
+
test('returns false when err is falsy', () => {
|
|
109
|
+
expect((0, src_1.isRetryableAxios)(null)).toBe(false);
|
|
110
|
+
expect((0, src_1.isRetryableAxios)(undefined)).toBe(false);
|
|
111
|
+
// also check other falsy-ish values that shouldn't be considered retryable
|
|
112
|
+
expect((0, src_1.isRetryableAxios)(false)).toBe(false);
|
|
113
|
+
expect((0, src_1.isRetryableAxios)('')).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
test('returns true for network/no-response axios errors', () => {
|
|
116
|
+
const networkErr = makeAxiosError({ noResponse: true });
|
|
117
|
+
expect(axios_1.default.isAxiosError(networkErr)).toBe(true); // sanity check
|
|
118
|
+
expect((0, src_1.isRetryableAxios)(networkErr)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
test('returns true for 500 server error', () => {
|
|
121
|
+
const serverErr = makeAxiosError({ response: { status: 500, data: {} } });
|
|
122
|
+
expect((0, src_1.isRetryableAxios)(serverErr)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
test('returns true for 503 server error', () => {
|
|
125
|
+
const serverErr = makeAxiosError({ response: { status: 503, data: {} } });
|
|
126
|
+
expect((0, src_1.isRetryableAxios)(serverErr)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
test('returns true for 429 rate limit', () => {
|
|
129
|
+
const rateErr = makeAxiosError({ response: { status: 429, data: {} } });
|
|
130
|
+
expect((0, src_1.isRetryableAxios)(rateErr)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
test('returns false for 400 client error (do not retry)', () => {
|
|
133
|
+
const badReq = makeAxiosError({ response: { status: 400, data: {} } });
|
|
134
|
+
expect((0, src_1.isRetryableAxios)(badReq)).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
test('delegates to isRetryableDefault for non-axios errors', () => {
|
|
137
|
+
// make a network-style Node error that isRetryableDefault recognizes
|
|
138
|
+
const nodeErr = { code: 'ECONNRESET', message: 'socket closed' };
|
|
139
|
+
expect((0, src_1.isRetryableDefault)(nodeErr)).toBe(true); // sanity
|
|
140
|
+
expect((0, src_1.isRetryableAxios)(nodeErr)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
test('returns false for non-retryable non-axios error', () => {
|
|
143
|
+
const normalErr = new Error('something bad but not retryable');
|
|
144
|
+
// isRetryableDefault likely returns false for a plain Error w/o hint
|
|
145
|
+
expect((0, src_1.isRetryableDefault)(normalErr)).toBe(false);
|
|
146
|
+
expect((0, src_1.isRetryableAxios)(normalErr)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('retry util', () => {
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
jest.restoreAllMocks();
|
|
152
|
+
});
|
|
153
|
+
test('returns result when fn succeeds immediately', async () => {
|
|
154
|
+
const fn = jest.fn(async () => 'ok');
|
|
155
|
+
const res = await (0, src_1.retry)(fn, { retries: 3 });
|
|
156
|
+
expect(res).toBe('ok');
|
|
157
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
158
|
+
});
|
|
159
|
+
test('retries on transient error and eventually succeeds; onRetry called expected times', async () => {
|
|
160
|
+
// fail twice with retryable error (gRPC code 14), then succeed
|
|
161
|
+
let calls = 0;
|
|
162
|
+
const fn = jest.fn(async () => {
|
|
163
|
+
calls++;
|
|
164
|
+
if (calls <= 2) {
|
|
165
|
+
const e = new Error('transient-gprc');
|
|
166
|
+
e.code = 14;
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
return 'ok-after-retries';
|
|
170
|
+
});
|
|
171
|
+
const onRetry = jest.fn();
|
|
172
|
+
// Make delays zero by forcing Math.random to 0 so tests are deterministic and fast
|
|
173
|
+
const mathSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
174
|
+
const res = await (0, src_1.retry)(fn, { retries: 5, baseDelayMs: 10, onRetry });
|
|
175
|
+
expect(res).toBe('ok-after-retries');
|
|
176
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
177
|
+
// onRetry called for the 2 transient failures
|
|
178
|
+
expect(onRetry).toHaveBeenCalledTimes(2);
|
|
179
|
+
mathSpy.mockRestore();
|
|
180
|
+
});
|
|
181
|
+
test('non-retryable error is rethrown immediately', async () => {
|
|
182
|
+
const fn = jest.fn(async () => {
|
|
183
|
+
const e = new Error('bad request');
|
|
184
|
+
e.status = 400;
|
|
185
|
+
throw e;
|
|
186
|
+
});
|
|
187
|
+
await expect((0, src_1.retry)(fn, { retries: 3 })).rejects.toThrow('bad request');
|
|
188
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
test('exhausts retries and throws final error preserving original', async () => {
|
|
191
|
+
const original = new Error('permanent transient-like');
|
|
192
|
+
original.code = 14; // make it retryable
|
|
193
|
+
const fn = jest.fn(async () => {
|
|
194
|
+
throw original;
|
|
195
|
+
});
|
|
196
|
+
// make delays 0 to run fast
|
|
197
|
+
jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
198
|
+
await expect((0, src_1.retry)(fn, { retries: 2, baseDelayMs: 1 })).rejects.toHaveProperty('original', original);
|
|
199
|
+
// initial call + 2 retries = 3 attempts
|
|
200
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
201
|
+
});
|
|
202
|
+
test('isRetryableDefault recognizes common shapes', () => {
|
|
203
|
+
const e1 = new Error('econnreset happened');
|
|
204
|
+
e1.code = 'ECONNRESET';
|
|
205
|
+
expect((0, src_1.isRetryableDefault)(e1)).toBe(true);
|
|
206
|
+
const e2 = new Error('http 500');
|
|
207
|
+
e2.response = { status: 500 };
|
|
208
|
+
expect((0, src_1.isRetryableDefault)(e2)).toBe(true);
|
|
209
|
+
const e3 = new Error('not retryable');
|
|
210
|
+
e3.status = 400;
|
|
211
|
+
expect((0, src_1.isRetryableDefault)(e3)).toBe(false);
|
|
212
|
+
const e4 = new Error('gRPC unavailable');
|
|
213
|
+
e4.code = 14;
|
|
214
|
+
expect((0, src_1.isRetryableDefault)(e4)).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
test('abort signal aborts immediately when already aborted', async () => {
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
controller.abort();
|
|
219
|
+
const fn = jest.fn(async () => 'should-not-run');
|
|
220
|
+
await expect((0, src_1.retry)(fn, { signal: controller.signal })).rejects.toThrow('Aborted');
|
|
221
|
+
// function should not be called at all because we check signal at loop start
|
|
222
|
+
expect(fn).toHaveBeenCalledTimes(0);
|
|
223
|
+
});
|
|
224
|
+
test('abort during wait causes rejection', async () => {
|
|
225
|
+
// Function will fail once (retryable), then retry -> sleep -> we abort during sleep.
|
|
226
|
+
let calls = 0;
|
|
227
|
+
const fn = jest.fn(async () => {
|
|
228
|
+
calls++;
|
|
229
|
+
if (calls === 1) {
|
|
230
|
+
const e = new Error('transient');
|
|
231
|
+
e.code = 14;
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
return 'should-not-get-here';
|
|
235
|
+
});
|
|
236
|
+
// Make delay large so we can abort during sleep (but we will abort synchronously)
|
|
237
|
+
jest.spyOn(Math, 'random').mockReturnValue(1); // picks the max exp
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
// Start the retry but abort immediately after allowing the utility to start.
|
|
240
|
+
// We do not await retry here yet; create a Promise and then abort and await
|
|
241
|
+
const p = (0, src_1.retry)(fn, { retries: 3, baseDelayMs: 200, signal: controller.signal });
|
|
242
|
+
// Abort immediately — this should cause the pending sleep to reject
|
|
243
|
+
controller.abort();
|
|
244
|
+
await expect(p).rejects.toThrow('Aborted');
|
|
245
|
+
});
|
|
246
|
+
test('abort during wait causes rejection and triggers onAbort (clears timeout)', async () => {
|
|
247
|
+
// Function will fail once (retryable), then retry -> sleep -> we abort during sleep.
|
|
248
|
+
let calls = 0;
|
|
249
|
+
const fn = jest.fn(async () => {
|
|
250
|
+
calls++;
|
|
251
|
+
if (calls === 1) {
|
|
252
|
+
const e = new Error('transient');
|
|
253
|
+
e.code = 14;
|
|
254
|
+
throw e;
|
|
255
|
+
}
|
|
256
|
+
return 'should-not-get-here';
|
|
257
|
+
});
|
|
258
|
+
// Make sure there is a non-zero delay so sleep schedules a timer
|
|
259
|
+
jest.spyOn(Math, 'random').mockReturnValue(0.5);
|
|
260
|
+
const controller = new AbortController();
|
|
261
|
+
// Start the retry (it will throw once, then call sleep)
|
|
262
|
+
const p = (0, src_1.retry)(fn, { retries: 3, baseDelayMs: 200, signal: controller.signal });
|
|
263
|
+
// Wait a tick so `sleep` has a chance to attach the abort listener and call setTimeout.
|
|
264
|
+
// Using setTimeout 0 (or small timeout) guarantees the timer and listener are attached.
|
|
265
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
266
|
+
// Now abort — this should call the onAbort handler inside sleep which clears the timeout
|
|
267
|
+
controller.abort();
|
|
268
|
+
await expect(p).rejects.toThrow('Aborted');
|
|
269
|
+
Math.random.mockRestore();
|
|
270
|
+
});
|
|
271
|
+
test('respects timeoutMs and breaks before waiting (covers timeout check)', async () => {
|
|
272
|
+
// Make the wrapped fn throw a retryable error once
|
|
273
|
+
const original = new Error('transient-timeout');
|
|
274
|
+
original.code = 14; // make it retryable via isRetryableDefault
|
|
275
|
+
const fn = jest.fn(async () => {
|
|
276
|
+
throw original;
|
|
277
|
+
});
|
|
278
|
+
// Force Math.random to 1 so delay === exp (max for that attempt)
|
|
279
|
+
const mathSpy = jest.spyOn(Math, 'random').mockReturnValue(1);
|
|
280
|
+
// Use a baseDelayMs larger than timeoutMs so elapsed + delay > timeoutMs triggers the "break"
|
|
281
|
+
await expect((0, src_1.retry)(fn, {
|
|
282
|
+
retries: 5,
|
|
283
|
+
baseDelayMs: 50, // exp for attempt 1 = 50
|
|
284
|
+
timeoutMs: 10 // 50 > 10 so we should break before sleeping
|
|
285
|
+
})).rejects.toHaveProperty('original', original);
|
|
286
|
+
// fn should only have been called once (no retry/sleep occurred)
|
|
287
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
288
|
+
mathSpy.mockRestore();
|
|
289
|
+
});
|
|
290
|
+
test('omitted opts uses default retries and eventually succeeds', async () => {
|
|
291
|
+
// Fail twice with retryable error (gRPC code 14), then succeed
|
|
292
|
+
let calls = 0;
|
|
293
|
+
const fn = jest.fn(async () => {
|
|
294
|
+
calls++;
|
|
295
|
+
if (calls <= 2) {
|
|
296
|
+
const e = new Error('transient');
|
|
297
|
+
e.code = 14;
|
|
298
|
+
throw e;
|
|
299
|
+
}
|
|
300
|
+
return 'ok-defaults';
|
|
301
|
+
});
|
|
302
|
+
// Make delays deterministic & small (Math.random = 0 -> delay = 0)
|
|
303
|
+
jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
304
|
+
const res = await (0, src_1.retry)(fn); // <- call WITHOUT opts to exercise default param
|
|
305
|
+
expect(res).toBe('ok-defaults');
|
|
306
|
+
// Called initial + 2 retries = 3 times
|
|
307
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
308
|
+
Math.random.mockRestore();
|
|
309
|
+
});
|
|
310
|
+
test('omitted opts exhausts default retries (5 attempts total) and throws', async () => {
|
|
311
|
+
const original = new Error('always-transient');
|
|
312
|
+
original.code = 14; // make it retryable
|
|
313
|
+
const fn = jest.fn(async () => {
|
|
314
|
+
throw original;
|
|
315
|
+
});
|
|
316
|
+
// Make delays 0 so test runs fast
|
|
317
|
+
jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
318
|
+
// Default retries is 4 -> total attempts = 5
|
|
319
|
+
await expect((0, src_1.retry)(fn)).rejects.toHaveProperty('original', original);
|
|
320
|
+
expect(fn).toHaveBeenCalledTimes(5);
|
|
321
|
+
Math.random.mockRestore();
|
|
322
|
+
});
|
|
323
|
+
test('final error message falls back to String(lastErr) when lastErr.message is undefined', async () => {
|
|
324
|
+
// original is a plain object (no .message)
|
|
325
|
+
const original = { code: 14, detail: 'plain-object-no-message' };
|
|
326
|
+
const fn = jest.fn(async () => {
|
|
327
|
+
throw original; // will be assigned to lastErr in retry()
|
|
328
|
+
});
|
|
329
|
+
// Make delays deterministic & fast
|
|
330
|
+
const mathSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
|
|
331
|
+
// retries = 1 -> attempt will be 1 in finalErr message
|
|
332
|
+
await expect((0, src_1.retry)(fn, { retries: 1, baseDelayMs: 1 })).rejects.toMatchObject({
|
|
333
|
+
message: expect.stringContaining('[object Object]'),
|
|
334
|
+
// Ensure original error was preserved on the thrown final error
|
|
335
|
+
original
|
|
336
|
+
});
|
|
337
|
+
mathSpy.mockRestore();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const secrets_1 = require("../src/secrets");
|
|
4
|
+
const secret_manager_1 = require("@google-cloud/secret-manager");
|
|
5
|
+
jest.mock('@google-cloud/secret-manager');
|
|
6
|
+
// Mock implementation
|
|
7
|
+
const mockAccessSecretVersion = jest.fn();
|
|
8
|
+
secret_manager_1.SecretManagerServiceClient.mockImplementation(() => ({
|
|
9
|
+
accessSecretVersion: mockAccessSecretVersion
|
|
10
|
+
}));
|
|
11
|
+
describe('getSecret', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
it('returns secret value as string', async () => {
|
|
16
|
+
mockAccessSecretVersion.mockResolvedValue([
|
|
17
|
+
{
|
|
18
|
+
payload: { data: Buffer.from('super-secret-value') }
|
|
19
|
+
}
|
|
20
|
+
]);
|
|
21
|
+
const result = await (0, secrets_1.getSecret)('test-project', 'test-secret');
|
|
22
|
+
expect(result).toBe('super-secret-value');
|
|
23
|
+
expect(mockAccessSecretVersion).toHaveBeenCalledWith({
|
|
24
|
+
name: 'projects/test-project/secrets/test-secret/versions/latest'
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it('throws if projectId is missing', async () => {
|
|
28
|
+
await expect((0, secrets_1.getSecret)('', 'secret')).rejects.toThrow('projectId is required');
|
|
29
|
+
});
|
|
30
|
+
it('throws if secretName is missing', async () => {
|
|
31
|
+
await expect((0, secrets_1.getSecret)('project', '')).rejects.toThrow('secretName is required');
|
|
32
|
+
});
|
|
33
|
+
it('returns empty string if payload or data is missing', async () => {
|
|
34
|
+
mockAccessSecretVersion.mockResolvedValue([{}]); // no payload
|
|
35
|
+
const result = await (0, secrets_1.getSecret)('project', 'secret');
|
|
36
|
+
expect(result).toBe('');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
4
|
+
// tests/createTask.retry.test.ts
|
|
5
|
+
const mockCreateTask = jest.fn();
|
|
6
|
+
const mockQueuePath = jest.fn((projectId, region, queueId) => `projects/${projectId}/locations/${region}/queues/${queueId}`);
|
|
7
|
+
// Mock Cloud Tasks client and protos (same as your original file)
|
|
8
|
+
jest.mock('@google-cloud/tasks', () => {
|
|
9
|
+
return {
|
|
10
|
+
CloudTasksClient: jest.fn(() => ({
|
|
11
|
+
createTask: mockCreateTask,
|
|
12
|
+
queuePath: mockQueuePath
|
|
13
|
+
})),
|
|
14
|
+
protos: {
|
|
15
|
+
google: {
|
|
16
|
+
cloud: {
|
|
17
|
+
tasks: {
|
|
18
|
+
v2: {
|
|
19
|
+
HttpMethod: {
|
|
20
|
+
POST: 'POST'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
// Mock the retry module so tests can inspect how createTask uses it.
|
|
30
|
+
// We'll set mockRetry behavior in each test as needed.
|
|
31
|
+
const realRetryModule = jest.requireActual('../src/retry');
|
|
32
|
+
const mockRetry = jest.fn();
|
|
33
|
+
jest.mock('../src/retry', () => ({
|
|
34
|
+
// spread real exports so isRetryableDefault, isRetryableAxios etc remain available
|
|
35
|
+
...realRetryModule,
|
|
36
|
+
// override only 'retry' with our mock
|
|
37
|
+
retry: (...args) => mockRetry(...args)
|
|
38
|
+
}));
|
|
39
|
+
const task_1 = require("../src/task");
|
|
40
|
+
describe('isRetryableCloudTasks', () => {
|
|
41
|
+
test('returns false for falsy errors', () => {
|
|
42
|
+
expect((0, task_1.isRetryableCloudTasks)(null)).toBe(false);
|
|
43
|
+
expect((0, task_1.isRetryableCloudTasks)(undefined)).toBe(false);
|
|
44
|
+
// other falsy-ish values should also be non-retryable
|
|
45
|
+
expect((0, task_1.isRetryableCloudTasks)(false)).toBe(false);
|
|
46
|
+
expect((0, task_1.isRetryableCloudTasks)('')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
test('treats ALREADY_EXISTS (code: 6) as NOT retryable', () => {
|
|
49
|
+
expect((0, task_1.isRetryableCloudTasks)({ code: 6 })).toBe(false);
|
|
50
|
+
expect((0, task_1.isRetryableCloudTasks)({ grpcCode: 6 })).toBe(false);
|
|
51
|
+
expect((0, task_1.isRetryableCloudTasks)({ code: 'ALREADY_EXISTS' })).toBe(false);
|
|
52
|
+
expect((0, task_1.isRetryableCloudTasks)({ code: 'already_exists' })).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
test('treats UNAVAILABLE (code: 14) as retryable', () => {
|
|
55
|
+
expect((0, task_1.isRetryableCloudTasks)({ code: 14 })).toBe(true);
|
|
56
|
+
expect((0, task_1.isRetryableCloudTasks)({ grpcCode: 14 })).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
test('treats HTTP 5xx and 429 as retryable, 4xx not retryable', () => {
|
|
59
|
+
expect((0, task_1.isRetryableCloudTasks)({ response: { status: 500 } })).toBe(true);
|
|
60
|
+
expect((0, task_1.isRetryableCloudTasks)({ response: { status: 503 } })).toBe(true);
|
|
61
|
+
expect((0, task_1.isRetryableCloudTasks)({ response: { status: 429 } })).toBe(true);
|
|
62
|
+
expect((0, task_1.isRetryableCloudTasks)({ response: { status: 400 } })).toBe(false);
|
|
63
|
+
expect((0, task_1.isRetryableCloudTasks)({ response: { status: 404 } })).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
test('treats node network errno like ECONNRESET as retryable (delegated to isRetryableDefault)', () => {
|
|
66
|
+
// isRetryableDefault recognizes typical node syscodes like 'ECONNRESET'
|
|
67
|
+
expect((0, task_1.isRetryableCloudTasks)({ code: 'ECONNRESET' })).toBe(true);
|
|
68
|
+
expect((0, task_1.isRetryableCloudTasks)({ code: 'ETIMEDOUT' })).toBe(true);
|
|
69
|
+
expect((0, task_1.isRetryableCloudTasks)({ code: 'EAI_AGAIN' })).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
test('plain Error without hints is not retryable', () => {
|
|
72
|
+
expect((0, task_1.isRetryableCloudTasks)(new Error('oops'))).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
test('non-standard objects without response but with message may or may not be retryable depending on heuristics', () => {
|
|
75
|
+
// This test documents the expected behavior: a message-only error generally is NOT retryable
|
|
76
|
+
// unless the message contains a retryable substring (e.g. "timeout" or "unavailable").
|
|
77
|
+
expect((0, task_1.isRetryableCloudTasks)({ message: 'socket closed' })).toBe(false);
|
|
78
|
+
expect((0, task_1.isRetryableCloudTasks)({ message: 'request timed out' })).toBe(true);
|
|
79
|
+
expect((0, task_1.isRetryableCloudTasks)({ message: 'temporarily unavailable' })).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('createTask (with retry)', () => {
|
|
83
|
+
const projectId = 'test-project';
|
|
84
|
+
const region = 'us-central1';
|
|
85
|
+
const queueId = 'test-queue';
|
|
86
|
+
const data = { test: 'data' };
|
|
87
|
+
const serviceAccount = 'test-sa@test.iam.gserviceaccount.com';
|
|
88
|
+
const audience = 'https://run-url';
|
|
89
|
+
const mockTaskName = 'projects/test-project/locations/us-central1/queues/test-queue/tasks/task-123';
|
|
90
|
+
let warnSpy;
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
mockCreateTask.mockReset();
|
|
93
|
+
mockQueuePath.mockClear();
|
|
94
|
+
mockRetry.mockReset();
|
|
95
|
+
// store the spy instance so we can assert against it safely
|
|
96
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
97
|
+
});
|
|
98
|
+
it('throws error if any required parameter is missing', async () => {
|
|
99
|
+
// Ensure retry / createTask are not called for parameter validation errors
|
|
100
|
+
await expect((0, task_1.createTask)('', 'region', 'queue', {}, 'serviceAccount', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
101
|
+
await expect((0, task_1.createTask)('project', '', 'queue', {}, 'serviceAccount', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
102
|
+
await expect((0, task_1.createTask)('project', 'region', '', {}, 'serviceAccount', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
103
|
+
await expect((0, task_1.createTask)('project', 'region', 'queue', {}, '', 'audience')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
104
|
+
await expect((0, task_1.createTask)('project', 'region', 'queue', {}, 'serviceAccount', '')).rejects.toThrow('Missing required parameters for Cloud Tasks setup');
|
|
105
|
+
expect(mockCreateTask).not.toHaveBeenCalled();
|
|
106
|
+
expect(mockRetry).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
it('should create a task and return task name (calls retry once)', async () => {
|
|
109
|
+
// Configure retry mock to simply execute the provided function immediately.
|
|
110
|
+
mockRetry.mockImplementation(async (fn) => {
|
|
111
|
+
return await fn();
|
|
112
|
+
});
|
|
113
|
+
mockCreateTask.mockResolvedValue([{ name: mockTaskName }]);
|
|
114
|
+
const result = await (0, task_1.createTask)(projectId, region, queueId, data, serviceAccount, audience);
|
|
115
|
+
expect(result).toBe(mockTaskName);
|
|
116
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(mockRetry).toHaveBeenCalledTimes(1);
|
|
118
|
+
// ensure the createTask was called with an object containing `task`
|
|
119
|
+
const calledArg = mockCreateTask.mock.calls[0][0];
|
|
120
|
+
expect(calledArg).toHaveProperty('task');
|
|
121
|
+
});
|
|
122
|
+
it('should throw error if task name is missing', async () => {
|
|
123
|
+
mockRetry.mockImplementation(async (fn) => {
|
|
124
|
+
return await fn();
|
|
125
|
+
});
|
|
126
|
+
mockCreateTask.mockResolvedValue([{}]); // Simulate missing name
|
|
127
|
+
await expect((0, task_1.createTask)(projectId, region, queueId, { foo: 'bar' }, serviceAccount, audience)).rejects.toThrow('Failed to create task: no name returned');
|
|
128
|
+
});
|
|
129
|
+
it('should include scheduleTime if delaySeconds is set', async () => {
|
|
130
|
+
mockRetry.mockImplementation(async (fn) => await fn());
|
|
131
|
+
mockCreateTask.mockResolvedValue([
|
|
132
|
+
{ name: 'projects/test/locations/us-central1/queues/test/tasks/task-456' }
|
|
133
|
+
]);
|
|
134
|
+
const delaySeconds = 120;
|
|
135
|
+
const before = Math.floor(Date.now() / 1000) + delaySeconds;
|
|
136
|
+
await (0, task_1.createTask)(projectId, region, queueId, { message: 'delayed' }, serviceAccount, audience, delaySeconds);
|
|
137
|
+
const taskArg = mockCreateTask.mock.calls[0][0].task;
|
|
138
|
+
const scheduleTime = taskArg.scheduleTime?.seconds;
|
|
139
|
+
const after = Math.floor(Date.now() / 1000) + delaySeconds;
|
|
140
|
+
expect(typeof scheduleTime).toBe('number');
|
|
141
|
+
expect(scheduleTime).toBeGreaterThanOrEqual(before);
|
|
142
|
+
expect(scheduleTime).toBeLessThanOrEqual(after);
|
|
143
|
+
});
|
|
144
|
+
it('retries on transient (UNAVAILABLE) error then succeeds', async () => {
|
|
145
|
+
// Simulate createTask: first call rejects with transient code 14, second call resolves.
|
|
146
|
+
mockCreateTask
|
|
147
|
+
.mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' })
|
|
148
|
+
.mockResolvedValueOnce([{ name: mockTaskName }]);
|
|
149
|
+
// Implement a small retry simulator: call fn(); if it throws then call opts.onRetry and call fn() again.
|
|
150
|
+
mockRetry.mockImplementation(async (fn, opts) => {
|
|
151
|
+
try {
|
|
152
|
+
return await fn();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
// simulate onRetry callback being invoked by retry implementation
|
|
156
|
+
if (opts?.onRetry) {
|
|
157
|
+
try {
|
|
158
|
+
opts.onRetry(err, 1, 0);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return await fn();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
const result = await (0, task_1.createTask)(projectId, region, queueId, data, serviceAccount, audience);
|
|
168
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(2);
|
|
169
|
+
expect(mockRetry).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(result).toBe(mockTaskName);
|
|
171
|
+
});
|
|
172
|
+
it('returns expected name if ALREADY_EXISTS and taskId provided (idempotent)', async () => {
|
|
173
|
+
// Simulate retry throwing ALREADY_EXISTS error (code: 6)
|
|
174
|
+
mockRetry.mockImplementation(async () => {
|
|
175
|
+
throw { code: 6, message: 'ALREADY_EXISTS' };
|
|
176
|
+
});
|
|
177
|
+
const taskId = 'task-123';
|
|
178
|
+
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/${taskId}`;
|
|
179
|
+
const result = await (0, task_1.createTask)(projectId, region, queueId, data, serviceAccount, audience, 0, {
|
|
180
|
+
taskId
|
|
181
|
+
});
|
|
182
|
+
// Should return expected name when ALREADY_EXISTS and taskId provided
|
|
183
|
+
expect(result).toBe(expectedName);
|
|
184
|
+
expect(mockRetry).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
it('passes an isRetryable to retry that treats ALREADY_EXISTS as non-retryable and UNAVAILABLE as retryable', async () => {
|
|
187
|
+
// Spy on the options passed to retry and invoke isRetryable with sample errors
|
|
188
|
+
mockRetry.mockImplementation(async (fn, opts) => {
|
|
189
|
+
// Sanity: opts should exist and include isRetryable
|
|
190
|
+
expect(opts).toBeDefined();
|
|
191
|
+
expect(typeof opts.isRetryable).toBe('function');
|
|
192
|
+
const isRetryable = opts.isRetryable;
|
|
193
|
+
// ALREADY_EXISTS (gRPC code 6) should NOT be retryable
|
|
194
|
+
expect(isRetryable({ code: 6 })).toBe(false);
|
|
195
|
+
// UNAVAILABLE (gRPC code 14) should be retryable
|
|
196
|
+
expect(isRetryable({ code: 14 })).toBe(true);
|
|
197
|
+
expect(isRetryable({ code: 'ECONNRESET' })).toBe(true);
|
|
198
|
+
// Execute the function normally for this test
|
|
199
|
+
return await fn();
|
|
200
|
+
});
|
|
201
|
+
mockCreateTask.mockResolvedValue([{ name: mockTaskName }]);
|
|
202
|
+
const result = await (0, task_1.createTask)(projectId, region, queueId, data, serviceAccount, audience);
|
|
203
|
+
expect(result).toBe(mockTaskName);
|
|
204
|
+
expect(mockRetry).toHaveBeenCalledTimes(1);
|
|
205
|
+
});
|
|
206
|
+
it('invokes onRetry with actual error message and logs it', async () => {
|
|
207
|
+
// first call fails with a retryable transient error, second call succeeds
|
|
208
|
+
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/1`;
|
|
209
|
+
mockCreateTask
|
|
210
|
+
.mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' }) // transient
|
|
211
|
+
.mockResolvedValueOnce([{ name: expectedName }]);
|
|
212
|
+
// Simulate retry behaviour: call fn(); if it throws, call onRetry(err, attempt, delay) then try fn() again.
|
|
213
|
+
mockRetry.mockImplementation(async (fn, opts) => {
|
|
214
|
+
try {
|
|
215
|
+
return await fn();
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (opts?.onRetry) {
|
|
219
|
+
opts.onRetry(err, 1, 0);
|
|
220
|
+
}
|
|
221
|
+
return await fn();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
const result = await (0, task_1.createTask)(projectId, region, queueId, data, serviceAccount, audience);
|
|
225
|
+
expect(result).toBe(expectedName);
|
|
226
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(2);
|
|
227
|
+
// assert onRetry logged the attempt and included the error message
|
|
228
|
+
expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
229
|
+
const warnMsg = warnSpy.mock.calls[0][0];
|
|
230
|
+
expect(warnMsg).toContain('createTask retry #1 in 0ms');
|
|
231
|
+
expect(warnMsg).toContain('UNAVAILABLE');
|
|
232
|
+
});
|
|
233
|
+
it('invokes onRetry with undefined error and logs "undefined" in message', async () => {
|
|
234
|
+
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/2`;
|
|
235
|
+
mockCreateTask
|
|
236
|
+
.mockRejectedValueOnce(new Error('boom')) // this will trigger catch in our mockRetry
|
|
237
|
+
.mockResolvedValueOnce([{ name: expectedName }]);
|
|
238
|
+
// This variant calls onRetry(undefined, ...) to exercise the err?.message ?? err branch.
|
|
239
|
+
mockRetry.mockImplementation(async (fn, opts) => {
|
|
240
|
+
try {
|
|
241
|
+
return await fn();
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
if (opts?.onRetry) {
|
|
245
|
+
opts.onRetry(undefined, 1, 0); // pass undefined as the error
|
|
246
|
+
}
|
|
247
|
+
return await fn();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
const result = await (0, task_1.createTask)(projectId, region, queueId, data, serviceAccount, audience);
|
|
251
|
+
expect(result).toBe(expectedName);
|
|
252
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(2);
|
|
253
|
+
// Assert the onRetry prefix was logged
|
|
254
|
+
expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
255
|
+
const warnMsg = warnSpy.mock.calls[0][0];
|
|
256
|
+
expect(warnMsg).toContain('createTask retry #1 in 0ms');
|
|
257
|
+
// instead of requiring the exact 'undefined' substring (which can vary),
|
|
258
|
+
// assert there's something logged in the error slot (non-empty)
|
|
259
|
+
const afterPrefix = warnMsg.replace('createTask retry #1 in 0ms — ', '');
|
|
260
|
+
expect(afterPrefix.length).toBeGreaterThan(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|