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.
@@ -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
+ })