opticedge-cloud-utils 1.1.13 → 1.1.15
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/.eslintignore +2 -2
- package/.eslintrc.js +30 -30
- package/.prettierignore +4 -4
- package/.prettierrc +8 -8
- package/dist/chunk.d.ts +1 -0
- package/dist/chunk.js +37 -0
- package/dist/env.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/jest.config.js +12 -12
- package/package.json +40 -40
- package/src/auth.ts +29 -29
- package/src/chunk.ts +33 -0
- package/src/db/mongo.ts +45 -45
- package/src/db/mongo2.ts +45 -45
- package/src/db/mongo3.ts +63 -63
- package/src/env.ts +10 -8
- package/src/index.ts +14 -13
- package/src/parser.ts +9 -9
- package/src/regex.ts +19 -19
- package/src/retry.ts +155 -155
- package/src/secrets.ts +21 -21
- package/src/task.ts +132 -132
- package/src/tw/utils.ts +18 -18
- package/src/tw/wallet.ts +68 -68
- package/src/validator.ts +4 -4
- package/tests/auth.test.ts +89 -89
- package/tests/chunk.test.ts +48 -0
- package/tests/db/mongo.test.ts +82 -82
- package/tests/db/mongo2.test.ts +96 -96
- package/tests/db/mongo3.test.ts +149 -149
- package/tests/env.test.ts +18 -18
- package/tests/parser.test.ts +26 -26
- package/tests/regex.test.ts +69 -69
- package/tests/retry.test.ts +416 -416
- package/tests/secrets.test.ts +44 -44
- package/tests/task.test.ts +337 -337
- package/tests/tw/utils.test.ts +29 -29
- package/tests/tw/wallet.test.ts +206 -206
- package/tests/validator.ts +37 -37
- package/tsconfig.json +17 -17
package/tests/task.test.ts
CHANGED
|
@@ -1,337 +1,337 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
// tests/createTask.retry.test.ts
|
|
3
|
-
const mockCreateTask = jest.fn()
|
|
4
|
-
const mockQueuePath = jest.fn(
|
|
5
|
-
(projectId: string, region: string, queueId: string) =>
|
|
6
|
-
`projects/${projectId}/locations/${region}/queues/${queueId}`
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
// Mock Cloud Tasks client and protos (same as your original file)
|
|
10
|
-
jest.mock('@google-cloud/tasks', () => {
|
|
11
|
-
return {
|
|
12
|
-
CloudTasksClient: jest.fn(() => ({
|
|
13
|
-
createTask: mockCreateTask,
|
|
14
|
-
queuePath: mockQueuePath
|
|
15
|
-
})),
|
|
16
|
-
protos: {
|
|
17
|
-
google: {
|
|
18
|
-
cloud: {
|
|
19
|
-
tasks: {
|
|
20
|
-
v2: {
|
|
21
|
-
HttpMethod: {
|
|
22
|
-
POST: 'POST'
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// Mock the retry module so tests can inspect how createTask uses it.
|
|
33
|
-
// We'll set mockRetry behavior in each test as needed.
|
|
34
|
-
const realRetryModule = jest.requireActual('../src/retry') as typeof import('../src/retry')
|
|
35
|
-
const mockRetry = jest.fn()
|
|
36
|
-
|
|
37
|
-
jest.mock('../src/retry', () => ({
|
|
38
|
-
// spread real exports so isRetryableDefault, isRetryableAxios etc remain available
|
|
39
|
-
...realRetryModule,
|
|
40
|
-
// override only 'retry' with our mock
|
|
41
|
-
retry: (...args: any[]) => mockRetry(...args)
|
|
42
|
-
}))
|
|
43
|
-
|
|
44
|
-
import { createTask, isRetryableCloudTasks } from '../src/task'
|
|
45
|
-
|
|
46
|
-
describe('isRetryableCloudTasks', () => {
|
|
47
|
-
test('returns false for falsy errors', () => {
|
|
48
|
-
expect(isRetryableCloudTasks(null)).toBe(false)
|
|
49
|
-
expect(isRetryableCloudTasks(undefined)).toBe(false)
|
|
50
|
-
// other falsy-ish values should also be non-retryable
|
|
51
|
-
expect(isRetryableCloudTasks(false as any)).toBe(false)
|
|
52
|
-
expect(isRetryableCloudTasks('' as any)).toBe(false)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('treats ALREADY_EXISTS (code: 6) as NOT retryable', () => {
|
|
56
|
-
expect(isRetryableCloudTasks({ code: 6 })).toBe(false)
|
|
57
|
-
expect(isRetryableCloudTasks({ grpcCode: 6 })).toBe(false)
|
|
58
|
-
expect(isRetryableCloudTasks({ code: 'ALREADY_EXISTS' })).toBe(false)
|
|
59
|
-
expect(isRetryableCloudTasks({ code: 'already_exists' })).toBe(false)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
test('treats UNAVAILABLE (code: 14) as retryable', () => {
|
|
63
|
-
expect(isRetryableCloudTasks({ code: 14 })).toBe(true)
|
|
64
|
-
expect(isRetryableCloudTasks({ grpcCode: 14 })).toBe(true)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
test('treats HTTP 5xx and 429 as retryable, 4xx not retryable', () => {
|
|
68
|
-
expect(isRetryableCloudTasks({ response: { status: 500 } })).toBe(true)
|
|
69
|
-
expect(isRetryableCloudTasks({ response: { status: 503 } })).toBe(true)
|
|
70
|
-
expect(isRetryableCloudTasks({ response: { status: 429 } })).toBe(true)
|
|
71
|
-
|
|
72
|
-
expect(isRetryableCloudTasks({ response: { status: 400 } })).toBe(false)
|
|
73
|
-
expect(isRetryableCloudTasks({ response: { status: 404 } })).toBe(false)
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('treats node network errno like ECONNRESET as retryable (delegated to isRetryableDefault)', () => {
|
|
77
|
-
// isRetryableDefault recognizes typical node syscodes like 'ECONNRESET'
|
|
78
|
-
expect(isRetryableCloudTasks({ code: 'ECONNRESET' })).toBe(true)
|
|
79
|
-
expect(isRetryableCloudTasks({ code: 'ETIMEDOUT' })).toBe(true)
|
|
80
|
-
expect(isRetryableCloudTasks({ code: 'EAI_AGAIN' })).toBe(true)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('plain Error without hints is not retryable', () => {
|
|
84
|
-
expect(isRetryableCloudTasks(new Error('oops'))).toBe(false)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('non-standard objects without response but with message may or may not be retryable depending on heuristics', () => {
|
|
88
|
-
// This test documents the expected behavior: a message-only error generally is NOT retryable
|
|
89
|
-
// unless the message contains a retryable substring (e.g. "timeout" or "unavailable").
|
|
90
|
-
expect(isRetryableCloudTasks({ message: 'socket closed' })).toBe(false)
|
|
91
|
-
expect(isRetryableCloudTasks({ message: 'request timed out' })).toBe(true)
|
|
92
|
-
expect(isRetryableCloudTasks({ message: 'temporarily unavailable' })).toBe(true)
|
|
93
|
-
})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
describe('createTask (with retry)', () => {
|
|
97
|
-
const projectId = 'test-project'
|
|
98
|
-
const region = 'us-central1'
|
|
99
|
-
const queueId = 'test-queue'
|
|
100
|
-
const data = { test: 'data' }
|
|
101
|
-
const serviceAccount = 'test-sa@test.iam.gserviceaccount.com'
|
|
102
|
-
const audience = 'https://run-url'
|
|
103
|
-
const mockTaskName =
|
|
104
|
-
'projects/test-project/locations/us-central1/queues/test-queue/tasks/task-123'
|
|
105
|
-
|
|
106
|
-
let warnSpy: jest.SpyInstance
|
|
107
|
-
|
|
108
|
-
beforeEach(() => {
|
|
109
|
-
mockCreateTask.mockReset()
|
|
110
|
-
mockQueuePath.mockClear()
|
|
111
|
-
mockRetry.mockReset()
|
|
112
|
-
// store the spy instance so we can assert against it safely
|
|
113
|
-
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('throws error if any required parameter is missing', async () => {
|
|
117
|
-
// Ensure retry / createTask are not called for parameter validation errors
|
|
118
|
-
await expect(
|
|
119
|
-
createTask('', 'region', 'queue', {}, 'serviceAccount', 'audience')
|
|
120
|
-
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
121
|
-
|
|
122
|
-
await expect(
|
|
123
|
-
createTask('project', '', 'queue', {}, 'serviceAccount', 'audience')
|
|
124
|
-
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
125
|
-
|
|
126
|
-
await expect(
|
|
127
|
-
createTask('project', 'region', '', {}, 'serviceAccount', 'audience')
|
|
128
|
-
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
129
|
-
|
|
130
|
-
await expect(createTask('project', 'region', 'queue', {}, '', 'audience')).rejects.toThrow(
|
|
131
|
-
'Missing required parameters for Cloud Tasks setup'
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
await expect(
|
|
135
|
-
createTask('project', 'region', 'queue', {}, 'serviceAccount', '')
|
|
136
|
-
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
137
|
-
|
|
138
|
-
expect(mockCreateTask).not.toHaveBeenCalled()
|
|
139
|
-
expect(mockRetry).not.toHaveBeenCalled()
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('should create a task and return task name (calls retry once)', async () => {
|
|
143
|
-
// Configure retry mock to simply execute the provided function immediately.
|
|
144
|
-
mockRetry.mockImplementation(async (fn: () => any) => {
|
|
145
|
-
return await fn()
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
|
|
149
|
-
|
|
150
|
-
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
151
|
-
|
|
152
|
-
expect(result).toBe(mockTaskName)
|
|
153
|
-
expect(mockCreateTask).toHaveBeenCalledTimes(1)
|
|
154
|
-
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
155
|
-
// ensure the createTask was called with an object containing `task`
|
|
156
|
-
const calledArg = mockCreateTask.mock.calls[0][0]
|
|
157
|
-
expect(calledArg).toHaveProperty('task')
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('should throw error if task name is missing', async () => {
|
|
161
|
-
mockRetry.mockImplementation(async (fn: () => any) => {
|
|
162
|
-
return await fn()
|
|
163
|
-
})
|
|
164
|
-
mockCreateTask.mockResolvedValue([{}]) // Simulate missing name
|
|
165
|
-
|
|
166
|
-
await expect(
|
|
167
|
-
createTask(projectId, region, queueId, { foo: 'bar' }, serviceAccount, audience)
|
|
168
|
-
).rejects.toThrow('Failed to create task: no name returned')
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('should include scheduleTime if delaySeconds is set', async () => {
|
|
172
|
-
mockRetry.mockImplementation(async (fn: () => any) => await fn())
|
|
173
|
-
mockCreateTask.mockResolvedValue([
|
|
174
|
-
{ name: 'projects/test/locations/us-central1/queues/test/tasks/task-456' }
|
|
175
|
-
])
|
|
176
|
-
|
|
177
|
-
const delaySeconds = 120
|
|
178
|
-
const before = Math.floor(Date.now() / 1000) + delaySeconds
|
|
179
|
-
|
|
180
|
-
await createTask(
|
|
181
|
-
projectId,
|
|
182
|
-
region,
|
|
183
|
-
queueId,
|
|
184
|
-
{ message: 'delayed' },
|
|
185
|
-
serviceAccount,
|
|
186
|
-
audience,
|
|
187
|
-
delaySeconds
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
const taskArg = mockCreateTask.mock.calls[0][0].task
|
|
191
|
-
const scheduleTime = taskArg.scheduleTime?.seconds
|
|
192
|
-
|
|
193
|
-
const after = Math.floor(Date.now() / 1000) + delaySeconds
|
|
194
|
-
|
|
195
|
-
expect(typeof scheduleTime).toBe('number')
|
|
196
|
-
expect(scheduleTime).toBeGreaterThanOrEqual(before)
|
|
197
|
-
expect(scheduleTime).toBeLessThanOrEqual(after)
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
it('retries on transient (UNAVAILABLE) error then succeeds', async () => {
|
|
201
|
-
// Simulate createTask: first call rejects with transient code 14, second call resolves.
|
|
202
|
-
mockCreateTask
|
|
203
|
-
.mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' })
|
|
204
|
-
.mockResolvedValueOnce([{ name: mockTaskName }])
|
|
205
|
-
|
|
206
|
-
// Implement a small retry simulator: call fn(); if it throws then call opts.onRetry and call fn() again.
|
|
207
|
-
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
208
|
-
try {
|
|
209
|
-
return await fn()
|
|
210
|
-
} catch (err) {
|
|
211
|
-
// simulate onRetry callback being invoked by retry implementation
|
|
212
|
-
if (opts?.onRetry) {
|
|
213
|
-
try {
|
|
214
|
-
opts.onRetry(err, 1, 0)
|
|
215
|
-
} catch {
|
|
216
|
-
// ignore
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return await fn()
|
|
220
|
-
}
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
224
|
-
|
|
225
|
-
expect(mockCreateTask).toHaveBeenCalledTimes(2)
|
|
226
|
-
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
227
|
-
expect(result).toBe(mockTaskName)
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
it('returns expected name if ALREADY_EXISTS and taskId provided (idempotent)', async () => {
|
|
231
|
-
// Simulate retry throwing ALREADY_EXISTS error (code: 6)
|
|
232
|
-
mockRetry.mockImplementation(async () => {
|
|
233
|
-
throw { code: 6, message: 'ALREADY_EXISTS' }
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
const taskId = 'task-123'
|
|
237
|
-
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/${taskId}`
|
|
238
|
-
|
|
239
|
-
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience, 0, {
|
|
240
|
-
taskId
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
// Should return expected name when ALREADY_EXISTS and taskId provided
|
|
244
|
-
expect(result).toBe(expectedName)
|
|
245
|
-
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
it('passes an isRetryable to retry that treats ALREADY_EXISTS as non-retryable and UNAVAILABLE as retryable', async () => {
|
|
249
|
-
// Spy on the options passed to retry and invoke isRetryable with sample errors
|
|
250
|
-
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
251
|
-
// Sanity: opts should exist and include isRetryable
|
|
252
|
-
expect(opts).toBeDefined()
|
|
253
|
-
expect(typeof opts.isRetryable).toBe('function')
|
|
254
|
-
|
|
255
|
-
const isRetryable = opts.isRetryable
|
|
256
|
-
|
|
257
|
-
// ALREADY_EXISTS (gRPC code 6) should NOT be retryable
|
|
258
|
-
expect(isRetryable({ code: 6 })).toBe(false)
|
|
259
|
-
// UNAVAILABLE (gRPC code 14) should be retryable
|
|
260
|
-
expect(isRetryable({ code: 14 })).toBe(true)
|
|
261
|
-
|
|
262
|
-
expect(isRetryable({ code: 'ECONNRESET' })).toBe(true)
|
|
263
|
-
|
|
264
|
-
// Execute the function normally for this test
|
|
265
|
-
return await fn()
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
|
|
269
|
-
|
|
270
|
-
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
271
|
-
expect(result).toBe(mockTaskName)
|
|
272
|
-
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
it('invokes onRetry with actual error message and logs it', async () => {
|
|
276
|
-
// first call fails with a retryable transient error, second call succeeds
|
|
277
|
-
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/1`
|
|
278
|
-
mockCreateTask
|
|
279
|
-
.mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' }) // transient
|
|
280
|
-
.mockResolvedValueOnce([{ name: expectedName }])
|
|
281
|
-
|
|
282
|
-
// Simulate retry behaviour: call fn(); if it throws, call onRetry(err, attempt, delay) then try fn() again.
|
|
283
|
-
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
284
|
-
try {
|
|
285
|
-
return await fn()
|
|
286
|
-
} catch (err) {
|
|
287
|
-
if (opts?.onRetry) {
|
|
288
|
-
opts.onRetry(err, 1, 0)
|
|
289
|
-
}
|
|
290
|
-
return await fn()
|
|
291
|
-
}
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
295
|
-
expect(result).toBe(expectedName)
|
|
296
|
-
expect(mockCreateTask).toHaveBeenCalledTimes(2)
|
|
297
|
-
|
|
298
|
-
// assert onRetry logged the attempt and included the error message
|
|
299
|
-
expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
300
|
-
const warnMsg = warnSpy.mock.calls[0][0] as string
|
|
301
|
-
expect(warnMsg).toContain('createTask retry #1 in 0ms')
|
|
302
|
-
expect(warnMsg).toContain('UNAVAILABLE')
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
it('invokes onRetry with undefined error and logs "undefined" in message', async () => {
|
|
306
|
-
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/2`
|
|
307
|
-
mockCreateTask
|
|
308
|
-
.mockRejectedValueOnce(new Error('boom')) // this will trigger catch in our mockRetry
|
|
309
|
-
.mockResolvedValueOnce([{ name: expectedName }])
|
|
310
|
-
|
|
311
|
-
// This variant calls onRetry(undefined, ...) to exercise the err?.message ?? err branch.
|
|
312
|
-
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
313
|
-
try {
|
|
314
|
-
return await fn()
|
|
315
|
-
} catch {
|
|
316
|
-
if (opts?.onRetry) {
|
|
317
|
-
opts.onRetry(undefined, 1, 0) // pass undefined as the error
|
|
318
|
-
}
|
|
319
|
-
return await fn()
|
|
320
|
-
}
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
324
|
-
expect(result).toBe(expectedName)
|
|
325
|
-
expect(mockCreateTask).toHaveBeenCalledTimes(2)
|
|
326
|
-
|
|
327
|
-
// Assert the onRetry prefix was logged
|
|
328
|
-
expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
329
|
-
const warnMsg = warnSpy.mock.calls[0][0] as string
|
|
330
|
-
expect(warnMsg).toContain('createTask retry #1 in 0ms')
|
|
331
|
-
|
|
332
|
-
// instead of requiring the exact 'undefined' substring (which can vary),
|
|
333
|
-
// assert there's something logged in the error slot (non-empty)
|
|
334
|
-
const afterPrefix = warnMsg.replace('createTask retry #1 in 0ms — ', '')
|
|
335
|
-
expect(afterPrefix.length).toBeGreaterThan(0)
|
|
336
|
-
})
|
|
337
|
-
})
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// tests/createTask.retry.test.ts
|
|
3
|
+
const mockCreateTask = jest.fn()
|
|
4
|
+
const mockQueuePath = jest.fn(
|
|
5
|
+
(projectId: string, region: string, queueId: string) =>
|
|
6
|
+
`projects/${projectId}/locations/${region}/queues/${queueId}`
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Mock Cloud Tasks client and protos (same as your original file)
|
|
10
|
+
jest.mock('@google-cloud/tasks', () => {
|
|
11
|
+
return {
|
|
12
|
+
CloudTasksClient: jest.fn(() => ({
|
|
13
|
+
createTask: mockCreateTask,
|
|
14
|
+
queuePath: mockQueuePath
|
|
15
|
+
})),
|
|
16
|
+
protos: {
|
|
17
|
+
google: {
|
|
18
|
+
cloud: {
|
|
19
|
+
tasks: {
|
|
20
|
+
v2: {
|
|
21
|
+
HttpMethod: {
|
|
22
|
+
POST: 'POST'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Mock the retry module so tests can inspect how createTask uses it.
|
|
33
|
+
// We'll set mockRetry behavior in each test as needed.
|
|
34
|
+
const realRetryModule = jest.requireActual('../src/retry') as typeof import('../src/retry')
|
|
35
|
+
const mockRetry = jest.fn()
|
|
36
|
+
|
|
37
|
+
jest.mock('../src/retry', () => ({
|
|
38
|
+
// spread real exports so isRetryableDefault, isRetryableAxios etc remain available
|
|
39
|
+
...realRetryModule,
|
|
40
|
+
// override only 'retry' with our mock
|
|
41
|
+
retry: (...args: any[]) => mockRetry(...args)
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
import { createTask, isRetryableCloudTasks } from '../src/task'
|
|
45
|
+
|
|
46
|
+
describe('isRetryableCloudTasks', () => {
|
|
47
|
+
test('returns false for falsy errors', () => {
|
|
48
|
+
expect(isRetryableCloudTasks(null)).toBe(false)
|
|
49
|
+
expect(isRetryableCloudTasks(undefined)).toBe(false)
|
|
50
|
+
// other falsy-ish values should also be non-retryable
|
|
51
|
+
expect(isRetryableCloudTasks(false as any)).toBe(false)
|
|
52
|
+
expect(isRetryableCloudTasks('' as any)).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('treats ALREADY_EXISTS (code: 6) as NOT retryable', () => {
|
|
56
|
+
expect(isRetryableCloudTasks({ code: 6 })).toBe(false)
|
|
57
|
+
expect(isRetryableCloudTasks({ grpcCode: 6 })).toBe(false)
|
|
58
|
+
expect(isRetryableCloudTasks({ code: 'ALREADY_EXISTS' })).toBe(false)
|
|
59
|
+
expect(isRetryableCloudTasks({ code: 'already_exists' })).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('treats UNAVAILABLE (code: 14) as retryable', () => {
|
|
63
|
+
expect(isRetryableCloudTasks({ code: 14 })).toBe(true)
|
|
64
|
+
expect(isRetryableCloudTasks({ grpcCode: 14 })).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('treats HTTP 5xx and 429 as retryable, 4xx not retryable', () => {
|
|
68
|
+
expect(isRetryableCloudTasks({ response: { status: 500 } })).toBe(true)
|
|
69
|
+
expect(isRetryableCloudTasks({ response: { status: 503 } })).toBe(true)
|
|
70
|
+
expect(isRetryableCloudTasks({ response: { status: 429 } })).toBe(true)
|
|
71
|
+
|
|
72
|
+
expect(isRetryableCloudTasks({ response: { status: 400 } })).toBe(false)
|
|
73
|
+
expect(isRetryableCloudTasks({ response: { status: 404 } })).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('treats node network errno like ECONNRESET as retryable (delegated to isRetryableDefault)', () => {
|
|
77
|
+
// isRetryableDefault recognizes typical node syscodes like 'ECONNRESET'
|
|
78
|
+
expect(isRetryableCloudTasks({ code: 'ECONNRESET' })).toBe(true)
|
|
79
|
+
expect(isRetryableCloudTasks({ code: 'ETIMEDOUT' })).toBe(true)
|
|
80
|
+
expect(isRetryableCloudTasks({ code: 'EAI_AGAIN' })).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('plain Error without hints is not retryable', () => {
|
|
84
|
+
expect(isRetryableCloudTasks(new Error('oops'))).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('non-standard objects without response but with message may or may not be retryable depending on heuristics', () => {
|
|
88
|
+
// This test documents the expected behavior: a message-only error generally is NOT retryable
|
|
89
|
+
// unless the message contains a retryable substring (e.g. "timeout" or "unavailable").
|
|
90
|
+
expect(isRetryableCloudTasks({ message: 'socket closed' })).toBe(false)
|
|
91
|
+
expect(isRetryableCloudTasks({ message: 'request timed out' })).toBe(true)
|
|
92
|
+
expect(isRetryableCloudTasks({ message: 'temporarily unavailable' })).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('createTask (with retry)', () => {
|
|
97
|
+
const projectId = 'test-project'
|
|
98
|
+
const region = 'us-central1'
|
|
99
|
+
const queueId = 'test-queue'
|
|
100
|
+
const data = { test: 'data' }
|
|
101
|
+
const serviceAccount = 'test-sa@test.iam.gserviceaccount.com'
|
|
102
|
+
const audience = 'https://run-url'
|
|
103
|
+
const mockTaskName =
|
|
104
|
+
'projects/test-project/locations/us-central1/queues/test-queue/tasks/task-123'
|
|
105
|
+
|
|
106
|
+
let warnSpy: jest.SpyInstance
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
mockCreateTask.mockReset()
|
|
110
|
+
mockQueuePath.mockClear()
|
|
111
|
+
mockRetry.mockReset()
|
|
112
|
+
// store the spy instance so we can assert against it safely
|
|
113
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('throws error if any required parameter is missing', async () => {
|
|
117
|
+
// Ensure retry / createTask are not called for parameter validation errors
|
|
118
|
+
await expect(
|
|
119
|
+
createTask('', 'region', 'queue', {}, 'serviceAccount', 'audience')
|
|
120
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
createTask('project', '', 'queue', {}, 'serviceAccount', 'audience')
|
|
124
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
createTask('project', 'region', '', {}, 'serviceAccount', 'audience')
|
|
128
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
129
|
+
|
|
130
|
+
await expect(createTask('project', 'region', 'queue', {}, '', 'audience')).rejects.toThrow(
|
|
131
|
+
'Missing required parameters for Cloud Tasks setup'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
createTask('project', 'region', 'queue', {}, 'serviceAccount', '')
|
|
136
|
+
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
137
|
+
|
|
138
|
+
expect(mockCreateTask).not.toHaveBeenCalled()
|
|
139
|
+
expect(mockRetry).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should create a task and return task name (calls retry once)', async () => {
|
|
143
|
+
// Configure retry mock to simply execute the provided function immediately.
|
|
144
|
+
mockRetry.mockImplementation(async (fn: () => any) => {
|
|
145
|
+
return await fn()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
|
|
149
|
+
|
|
150
|
+
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
151
|
+
|
|
152
|
+
expect(result).toBe(mockTaskName)
|
|
153
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(1)
|
|
154
|
+
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
155
|
+
// ensure the createTask was called with an object containing `task`
|
|
156
|
+
const calledArg = mockCreateTask.mock.calls[0][0]
|
|
157
|
+
expect(calledArg).toHaveProperty('task')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('should throw error if task name is missing', async () => {
|
|
161
|
+
mockRetry.mockImplementation(async (fn: () => any) => {
|
|
162
|
+
return await fn()
|
|
163
|
+
})
|
|
164
|
+
mockCreateTask.mockResolvedValue([{}]) // Simulate missing name
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
createTask(projectId, region, queueId, { foo: 'bar' }, serviceAccount, audience)
|
|
168
|
+
).rejects.toThrow('Failed to create task: no name returned')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should include scheduleTime if delaySeconds is set', async () => {
|
|
172
|
+
mockRetry.mockImplementation(async (fn: () => any) => await fn())
|
|
173
|
+
mockCreateTask.mockResolvedValue([
|
|
174
|
+
{ name: 'projects/test/locations/us-central1/queues/test/tasks/task-456' }
|
|
175
|
+
])
|
|
176
|
+
|
|
177
|
+
const delaySeconds = 120
|
|
178
|
+
const before = Math.floor(Date.now() / 1000) + delaySeconds
|
|
179
|
+
|
|
180
|
+
await createTask(
|
|
181
|
+
projectId,
|
|
182
|
+
region,
|
|
183
|
+
queueId,
|
|
184
|
+
{ message: 'delayed' },
|
|
185
|
+
serviceAccount,
|
|
186
|
+
audience,
|
|
187
|
+
delaySeconds
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const taskArg = mockCreateTask.mock.calls[0][0].task
|
|
191
|
+
const scheduleTime = taskArg.scheduleTime?.seconds
|
|
192
|
+
|
|
193
|
+
const after = Math.floor(Date.now() / 1000) + delaySeconds
|
|
194
|
+
|
|
195
|
+
expect(typeof scheduleTime).toBe('number')
|
|
196
|
+
expect(scheduleTime).toBeGreaterThanOrEqual(before)
|
|
197
|
+
expect(scheduleTime).toBeLessThanOrEqual(after)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('retries on transient (UNAVAILABLE) error then succeeds', async () => {
|
|
201
|
+
// Simulate createTask: first call rejects with transient code 14, second call resolves.
|
|
202
|
+
mockCreateTask
|
|
203
|
+
.mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' })
|
|
204
|
+
.mockResolvedValueOnce([{ name: mockTaskName }])
|
|
205
|
+
|
|
206
|
+
// Implement a small retry simulator: call fn(); if it throws then call opts.onRetry and call fn() again.
|
|
207
|
+
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
208
|
+
try {
|
|
209
|
+
return await fn()
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// simulate onRetry callback being invoked by retry implementation
|
|
212
|
+
if (opts?.onRetry) {
|
|
213
|
+
try {
|
|
214
|
+
opts.onRetry(err, 1, 0)
|
|
215
|
+
} catch {
|
|
216
|
+
// ignore
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return await fn()
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
224
|
+
|
|
225
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(2)
|
|
226
|
+
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
227
|
+
expect(result).toBe(mockTaskName)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('returns expected name if ALREADY_EXISTS and taskId provided (idempotent)', async () => {
|
|
231
|
+
// Simulate retry throwing ALREADY_EXISTS error (code: 6)
|
|
232
|
+
mockRetry.mockImplementation(async () => {
|
|
233
|
+
throw { code: 6, message: 'ALREADY_EXISTS' }
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const taskId = 'task-123'
|
|
237
|
+
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/${taskId}`
|
|
238
|
+
|
|
239
|
+
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience, 0, {
|
|
240
|
+
taskId
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// Should return expected name when ALREADY_EXISTS and taskId provided
|
|
244
|
+
expect(result).toBe(expectedName)
|
|
245
|
+
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('passes an isRetryable to retry that treats ALREADY_EXISTS as non-retryable and UNAVAILABLE as retryable', async () => {
|
|
249
|
+
// Spy on the options passed to retry and invoke isRetryable with sample errors
|
|
250
|
+
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
251
|
+
// Sanity: opts should exist and include isRetryable
|
|
252
|
+
expect(opts).toBeDefined()
|
|
253
|
+
expect(typeof opts.isRetryable).toBe('function')
|
|
254
|
+
|
|
255
|
+
const isRetryable = opts.isRetryable
|
|
256
|
+
|
|
257
|
+
// ALREADY_EXISTS (gRPC code 6) should NOT be retryable
|
|
258
|
+
expect(isRetryable({ code: 6 })).toBe(false)
|
|
259
|
+
// UNAVAILABLE (gRPC code 14) should be retryable
|
|
260
|
+
expect(isRetryable({ code: 14 })).toBe(true)
|
|
261
|
+
|
|
262
|
+
expect(isRetryable({ code: 'ECONNRESET' })).toBe(true)
|
|
263
|
+
|
|
264
|
+
// Execute the function normally for this test
|
|
265
|
+
return await fn()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
|
|
269
|
+
|
|
270
|
+
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
271
|
+
expect(result).toBe(mockTaskName)
|
|
272
|
+
expect(mockRetry).toHaveBeenCalledTimes(1)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('invokes onRetry with actual error message and logs it', async () => {
|
|
276
|
+
// first call fails with a retryable transient error, second call succeeds
|
|
277
|
+
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/1`
|
|
278
|
+
mockCreateTask
|
|
279
|
+
.mockRejectedValueOnce({ code: 14, message: 'UNAVAILABLE' }) // transient
|
|
280
|
+
.mockResolvedValueOnce([{ name: expectedName }])
|
|
281
|
+
|
|
282
|
+
// Simulate retry behaviour: call fn(); if it throws, call onRetry(err, attempt, delay) then try fn() again.
|
|
283
|
+
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
284
|
+
try {
|
|
285
|
+
return await fn()
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (opts?.onRetry) {
|
|
288
|
+
opts.onRetry(err, 1, 0)
|
|
289
|
+
}
|
|
290
|
+
return await fn()
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
295
|
+
expect(result).toBe(expectedName)
|
|
296
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(2)
|
|
297
|
+
|
|
298
|
+
// assert onRetry logged the attempt and included the error message
|
|
299
|
+
expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
300
|
+
const warnMsg = warnSpy.mock.calls[0][0] as string
|
|
301
|
+
expect(warnMsg).toContain('createTask retry #1 in 0ms')
|
|
302
|
+
expect(warnMsg).toContain('UNAVAILABLE')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('invokes onRetry with undefined error and logs "undefined" in message', async () => {
|
|
306
|
+
const expectedName = `projects/${projectId}/locations/${region}/queues/${queueId}/tasks/2`
|
|
307
|
+
mockCreateTask
|
|
308
|
+
.mockRejectedValueOnce(new Error('boom')) // this will trigger catch in our mockRetry
|
|
309
|
+
.mockResolvedValueOnce([{ name: expectedName }])
|
|
310
|
+
|
|
311
|
+
// This variant calls onRetry(undefined, ...) to exercise the err?.message ?? err branch.
|
|
312
|
+
mockRetry.mockImplementation(async (fn: () => any, opts?: any) => {
|
|
313
|
+
try {
|
|
314
|
+
return await fn()
|
|
315
|
+
} catch {
|
|
316
|
+
if (opts?.onRetry) {
|
|
317
|
+
opts.onRetry(undefined, 1, 0) // pass undefined as the error
|
|
318
|
+
}
|
|
319
|
+
return await fn()
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
324
|
+
expect(result).toBe(expectedName)
|
|
325
|
+
expect(mockCreateTask).toHaveBeenCalledTimes(2)
|
|
326
|
+
|
|
327
|
+
// Assert the onRetry prefix was logged
|
|
328
|
+
expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
329
|
+
const warnMsg = warnSpy.mock.calls[0][0] as string
|
|
330
|
+
expect(warnMsg).toContain('createTask retry #1 in 0ms')
|
|
331
|
+
|
|
332
|
+
// instead of requiring the exact 'undefined' substring (which can vary),
|
|
333
|
+
// assert there's something logged in the error slot (non-empty)
|
|
334
|
+
const afterPrefix = warnMsg.replace('createTask retry #1 in 0ms — ', '')
|
|
335
|
+
expect(afterPrefix.length).toBeGreaterThan(0)
|
|
336
|
+
})
|
|
337
|
+
})
|