opticedge-cloud-utils 1.1.7 → 1.1.9
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/retry.d.ts +2 -0
- package/dist/retry.js +38 -10
- package/dist/task.d.ts +12 -1
- package/dist/task.js +72 -6
- package/dist/tw/wallet.js +35 -9
- package/package.json +1 -1
- package/src/retry.ts +37 -13
- package/src/task.ts +86 -6
- package/src/tw/wallet.ts +39 -9
- package/tests/retry.test.ts +119 -1
- package/tests/task.test.ts +250 -34
- package/tests/tw/wallet.test.ts +146 -28
package/dist/retry.d.ts
CHANGED
|
@@ -7,5 +7,7 @@ export type RetryOptions = {
|
|
|
7
7
|
isRetryable?: (err: any) => boolean;
|
|
8
8
|
onRetry?: (err: any, attempt: number, nextDelayMs: number) => void;
|
|
9
9
|
};
|
|
10
|
+
export declare function sleep(ms: number, signal?: AbortSignal | null): Promise<void>;
|
|
10
11
|
export declare function isRetryableDefault(err: any): boolean;
|
|
12
|
+
export declare function isRetryableAxios(err: any): boolean;
|
|
11
13
|
export declare function retry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
|
package/dist/retry.js
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
// src/retry.ts
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
4
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
5
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
6
|
+
};
|
|
2
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.sleep = sleep;
|
|
3
9
|
exports.isRetryableDefault = isRetryableDefault;
|
|
10
|
+
exports.isRetryableAxios = isRetryableAxios;
|
|
4
11
|
exports.retry = retry;
|
|
5
|
-
|
|
12
|
+
const axios_1 = __importDefault(require("axios"));
|
|
13
|
+
async function sleep(ms, signal) {
|
|
6
14
|
return new Promise((resolve, reject) => {
|
|
7
15
|
if (signal?.aborted)
|
|
8
16
|
return reject(new Error('Aborted'));
|
|
9
17
|
const t = setTimeout(() => {
|
|
10
|
-
signal?.removeEventListener('abort', onAbort);
|
|
18
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
11
19
|
resolve();
|
|
12
20
|
}, ms);
|
|
13
21
|
function onAbort() {
|
|
14
22
|
clearTimeout(t);
|
|
23
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
15
24
|
reject(new Error('Aborted'));
|
|
16
25
|
}
|
|
17
|
-
signal?.addEventListener('abort', onAbort);
|
|
26
|
+
signal?.addEventListener?.('abort', onAbort);
|
|
18
27
|
});
|
|
19
28
|
}
|
|
20
29
|
function isRetryableDefault(err) {
|
|
@@ -55,6 +64,24 @@ function isRetryableDefault(err) {
|
|
|
55
64
|
return true;
|
|
56
65
|
return false;
|
|
57
66
|
}
|
|
67
|
+
function isRetryableAxios(err) {
|
|
68
|
+
if (!err)
|
|
69
|
+
return false;
|
|
70
|
+
// If axios knows about it:
|
|
71
|
+
if (axios_1.default.isAxiosError(err)) {
|
|
72
|
+
// No response -> network error / timeout -> retry
|
|
73
|
+
if (!err.response)
|
|
74
|
+
return true;
|
|
75
|
+
const status = err.response.status;
|
|
76
|
+
// retry on 429 or 5xx
|
|
77
|
+
if (status === 429 || (status >= 500 && status < 600))
|
|
78
|
+
return true;
|
|
79
|
+
// 4xx client errors -> do not retry
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
// Fallback to your existing heuristics (gRPC codes, node syscodes, etc.)
|
|
83
|
+
return isRetryableDefault(err);
|
|
84
|
+
}
|
|
58
85
|
async function retry(fn, opts = {}) {
|
|
59
86
|
const retries = opts.retries ?? 4;
|
|
60
87
|
const base = opts.baseDelayMs ?? 200;
|
|
@@ -66,7 +93,8 @@ async function retry(fn, opts = {}) {
|
|
|
66
93
|
const start = Date.now();
|
|
67
94
|
let attempt = 0;
|
|
68
95
|
let lastErr;
|
|
69
|
-
|
|
96
|
+
// attempt = number of retries already performed; initial try is attempt==0
|
|
97
|
+
while (attempt <= retries) {
|
|
70
98
|
if (signal?.aborted)
|
|
71
99
|
throw new Error('Aborted');
|
|
72
100
|
try {
|
|
@@ -77,11 +105,11 @@ async function retry(fn, opts = {}) {
|
|
|
77
105
|
if (!isRetryable(err)) {
|
|
78
106
|
throw err;
|
|
79
107
|
}
|
|
80
|
-
if (attempt >= retries)
|
|
108
|
+
if (attempt >= retries)
|
|
81
109
|
break;
|
|
82
|
-
|
|
110
|
+
// compute next retry (1-based)
|
|
83
111
|
const nextAttempt = attempt + 1;
|
|
84
|
-
const exp = Math.min(max, base * Math.pow(2,
|
|
112
|
+
const exp = Math.min(max, base * Math.pow(2, nextAttempt - 1));
|
|
85
113
|
const delay = Math.floor(Math.random() * exp);
|
|
86
114
|
if (typeof timeoutMs === 'number') {
|
|
87
115
|
const elapsed = Date.now() - start;
|
|
@@ -90,11 +118,11 @@ async function retry(fn, opts = {}) {
|
|
|
90
118
|
}
|
|
91
119
|
}
|
|
92
120
|
try {
|
|
121
|
+
// pass 1-based attempt to onRetry to mean "this is the upcoming retry number"
|
|
93
122
|
onRetry?.(err, nextAttempt, delay);
|
|
94
|
-
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
95
123
|
}
|
|
96
|
-
catch
|
|
97
|
-
|
|
124
|
+
catch {
|
|
125
|
+
/* ignore onRetry errors */
|
|
98
126
|
}
|
|
99
127
|
await sleep(delay, signal);
|
|
100
128
|
attempt = nextAttempt;
|
package/dist/task.d.ts
CHANGED
|
@@ -1 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
import { RetryOptions } from './retry';
|
|
2
|
+
export type CreateTaskOpts = {
|
|
3
|
+
taskId?: string;
|
|
4
|
+
retryOptions?: RetryOptions;
|
|
5
|
+
};
|
|
6
|
+
export declare function isRetryableCloudTasks(err: any): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Create a Cloud Task with retry logic.
|
|
9
|
+
* If opts.taskId is provided, the task name will be parent + '/tasks/' + taskId — this
|
|
10
|
+
* makes the operation idempotent (create will fail with ALREADY_EXISTS if it already exists).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createTask(projectId: string, region: string, queueId: string, data: unknown, serviceAccount: string, audience: string, delaySeconds?: number, opts?: CreateTaskOpts): Promise<string>;
|
package/dist/task.js
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isRetryableCloudTasks = isRetryableCloudTasks;
|
|
3
4
|
exports.createTask = createTask;
|
|
5
|
+
// src/cloud-tasks.ts
|
|
4
6
|
const tasks_1 = require("@google-cloud/tasks");
|
|
7
|
+
const retry_1 = require("./retry");
|
|
5
8
|
const tasksClient = new tasks_1.CloudTasksClient();
|
|
6
|
-
|
|
9
|
+
// cloud-tasks errors can be gRPC-style (numeric code) or an Error with code string.
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
function isRetryableCloudTasks(err) {
|
|
12
|
+
if (!err)
|
|
13
|
+
return false;
|
|
14
|
+
// If error explicitly says ALREADY_EXISTS, do not retry (client already has the task)
|
|
15
|
+
// gRPC ALREADY_EXISTS numeric code is 6
|
|
16
|
+
if (err?.code === 6 ||
|
|
17
|
+
err?.grpcCode === 6 ||
|
|
18
|
+
String(err?.code).toUpperCase().includes('ALREADY_EXISTS')) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
// Otherwise delegate to your general retryable heuristics (network / 14=UNAVAILABLE / 5xx etc.)
|
|
22
|
+
return (0, retry_1.isRetryableDefault)(err);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Create a Cloud Task with retry logic.
|
|
26
|
+
* If opts.taskId is provided, the task name will be parent + '/tasks/' + taskId — this
|
|
27
|
+
* makes the operation idempotent (create will fail with ALREADY_EXISTS if it already exists).
|
|
28
|
+
*/
|
|
29
|
+
async function createTask(projectId, region, queueId, data, serviceAccount, audience, delaySeconds = 0, opts = {}) {
|
|
7
30
|
if (!projectId || !region || !queueId || !serviceAccount || !audience) {
|
|
8
31
|
throw new Error('Missing required parameters for Cloud Tasks setup');
|
|
9
32
|
}
|
|
@@ -14,6 +37,7 @@ async function createTask(projectId, region, queueId, data, serviceAccount, audi
|
|
|
14
37
|
seconds: Math.floor(now + delaySeconds)
|
|
15
38
|
}
|
|
16
39
|
: undefined;
|
|
40
|
+
// Build the base task
|
|
17
41
|
const task = {
|
|
18
42
|
httpRequest: {
|
|
19
43
|
httpMethod: tasks_1.protos.google.cloud.tasks.v2.HttpMethod.POST,
|
|
@@ -29,10 +53,52 @@ async function createTask(projectId, region, queueId, data, serviceAccount, audi
|
|
|
29
53
|
},
|
|
30
54
|
scheduleTime: scheduledTime
|
|
31
55
|
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
// Optionally set a deterministic name to make task creation idempotent.
|
|
57
|
+
// The name must be full resource path: projects/PROJECT/locations/REGION/queues/QUEUE/tasks/TASK_ID
|
|
58
|
+
if (opts.taskId) {
|
|
59
|
+
// sanitize/validate taskId if you want; Cloud Tasks requires certain name rules.
|
|
60
|
+
task.name = `${parent}/tasks/${opts.taskId}`;
|
|
61
|
+
}
|
|
62
|
+
// retry options with sensible defaults; allow caller to override via opts.retryOptions
|
|
63
|
+
const retryOpts = {
|
|
64
|
+
retries: 3,
|
|
65
|
+
baseDelayMs: 200,
|
|
66
|
+
maxDelayMs: 3000,
|
|
67
|
+
isRetryable: isRetryableCloudTasks,
|
|
68
|
+
onRetry: (err, attempt, delay) => {
|
|
69
|
+
console.warn(`createTask retry #${attempt} in ${delay}ms — ${String(err?.message ?? err)}`);
|
|
70
|
+
},
|
|
71
|
+
...opts.retryOptions // allow overrides
|
|
72
|
+
};
|
|
73
|
+
// the unit-of-work we want to retry
|
|
74
|
+
const doCreate = async () => {
|
|
75
|
+
const [response] = await tasksClient.createTask({ parent, task });
|
|
76
|
+
if (!response?.name) {
|
|
77
|
+
// unexpected response shape — treat as non-retryable final failure
|
|
78
|
+
throw new Error('Failed to create task: no name returned');
|
|
79
|
+
}
|
|
80
|
+
return response.name;
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const name = await (0, retry_1.retry)(() => doCreate(), retryOpts);
|
|
84
|
+
console.log(`✅ Created Cloud Task: ${name}`);
|
|
85
|
+
return name;
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// If ALREADY_EXISTS happened (and was not considered retryable), you may prefer to
|
|
90
|
+
// return the constructed task name (if provided) rather than null/throw.
|
|
91
|
+
if (opts.taskId) {
|
|
92
|
+
const expectedName = `${parent}/tasks/${opts.taskId}`;
|
|
93
|
+
// If we got ALREADY_EXISTS, treat as success and return existing name
|
|
94
|
+
if (err?.original?.code === 6 ||
|
|
95
|
+
err?.code === 6 ||
|
|
96
|
+
String(err?.original?.code).toUpperCase().includes('ALREADY_EXISTS')) {
|
|
97
|
+
console.warn('createTask — task already exists, returning existing name', expectedName);
|
|
98
|
+
return expectedName;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
console.error('createTask — final failure after retries', { error: err?.message ?? err });
|
|
102
|
+
throw err; // rethrow (or return null if you prefer)
|
|
35
103
|
}
|
|
36
|
-
console.log(`✅ Created Cloud Task: ${response.name}`);
|
|
37
|
-
return response.name;
|
|
38
104
|
}
|
package/dist/tw/wallet.js
CHANGED
|
@@ -6,27 +6,53 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.pregenerateInAppWallet = pregenerateInAppWallet;
|
|
7
7
|
const axios_1 = __importDefault(require("axios"));
|
|
8
8
|
const secrets_1 = require("../secrets");
|
|
9
|
+
const retry_1 = require("../retry");
|
|
9
10
|
async function pregenerateInAppWallet(projectId, twClientId, twSecretKeyName, email) {
|
|
10
11
|
const TW_SECRET_KEY = await (0, secrets_1.getSecret)(projectId, twSecretKeyName);
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
if (!TW_SECRET_KEY) {
|
|
13
|
+
console.error('Missing TW secret key', { projectId, twSecretKeyName });
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const doRequest = async () => {
|
|
17
|
+
const res = await axios_1.default.post('https://in-app-wallet.thirdweb.com/api/v1/pregenerate', { strategy: 'email', email }, {
|
|
13
18
|
headers: {
|
|
14
19
|
'x-secret-key': TW_SECRET_KEY,
|
|
15
20
|
'x-client-id': twClientId,
|
|
16
21
|
'Content-Type': 'application/json'
|
|
17
22
|
},
|
|
18
|
-
timeout:
|
|
23
|
+
timeout: 10000
|
|
19
24
|
});
|
|
20
|
-
const wallet =
|
|
25
|
+
const wallet = res.data?.wallet;
|
|
21
26
|
if (!wallet || !wallet.address) {
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
// server returned 2xx but unexpected shape — don't retry, treat as final failure
|
|
28
|
+
console.error('Invalid wallet response shape', { status: res.status, data: res.data });
|
|
29
|
+
throw new Error('Invalid wallet response');
|
|
24
30
|
}
|
|
25
|
-
console.log('Wallet pregeneration response:', wallet.address);
|
|
26
31
|
return wallet.address;
|
|
32
|
+
};
|
|
33
|
+
const opts = {
|
|
34
|
+
retries: 3,
|
|
35
|
+
baseDelayMs: 300,
|
|
36
|
+
maxDelayMs: 2000,
|
|
37
|
+
timeoutMs: 15000,
|
|
38
|
+
isRetryable: retry_1.isRetryableAxios,
|
|
39
|
+
onRetry: (err, attempt, delay) => {
|
|
40
|
+
console.warn(`pregenerateInAppWallet: retry #${attempt} in ${delay}ms — ${String(err?.message)}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
const address = await (0, retry_1.retry)(() => doRequest(), opts);
|
|
45
|
+
return address;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
47
|
}
|
|
28
|
-
catch (
|
|
29
|
-
|
|
48
|
+
catch (err) {
|
|
49
|
+
// final failure after retries
|
|
50
|
+
console.error('pregenerateInAppWallet: final failure', {
|
|
51
|
+
message: err?.message,
|
|
52
|
+
// include axios response data when possible
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
responseData: err?.response?.data ?? err?.cause ?? err.original ?? null
|
|
55
|
+
});
|
|
30
56
|
return null;
|
|
31
57
|
}
|
|
32
58
|
}
|
package/package.json
CHANGED
package/src/retry.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// src/retry.ts
|
|
2
2
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
|
|
4
|
+
import axios from 'axios'
|
|
5
|
+
|
|
3
6
|
export type RetryOptions = {
|
|
4
7
|
retries?: number
|
|
5
8
|
baseDelayMs?: number
|
|
@@ -10,18 +13,21 @@ export type RetryOptions = {
|
|
|
10
13
|
onRetry?: (err: any, attempt: number, nextDelayMs: number) => void
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
function sleep(ms: number, signal?: AbortSignal | null) {
|
|
16
|
+
export async function sleep(ms: number, signal?: AbortSignal | null) {
|
|
14
17
|
return new Promise<void>((resolve, reject) => {
|
|
15
18
|
if (signal?.aborted) return reject(new Error('Aborted'))
|
|
16
19
|
const t = setTimeout(() => {
|
|
17
|
-
signal?.removeEventListener('abort', onAbort)
|
|
20
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
18
21
|
resolve()
|
|
19
22
|
}, ms)
|
|
23
|
+
|
|
20
24
|
function onAbort() {
|
|
21
25
|
clearTimeout(t)
|
|
26
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
22
27
|
reject(new Error('Aborted'))
|
|
23
28
|
}
|
|
24
|
-
|
|
29
|
+
|
|
30
|
+
signal?.addEventListener?.('abort', onAbort)
|
|
25
31
|
})
|
|
26
32
|
}
|
|
27
33
|
|
|
@@ -68,6 +74,26 @@ export function isRetryableDefault(err: any): boolean {
|
|
|
68
74
|
return false
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
export function isRetryableAxios(err: any): boolean {
|
|
78
|
+
if (!err) return false
|
|
79
|
+
|
|
80
|
+
// If axios knows about it:
|
|
81
|
+
if (axios.isAxiosError(err)) {
|
|
82
|
+
// No response -> network error / timeout -> retry
|
|
83
|
+
if (!err.response) return true
|
|
84
|
+
|
|
85
|
+
const status = err.response.status
|
|
86
|
+
// retry on 429 or 5xx
|
|
87
|
+
if (status === 429 || (status >= 500 && status < 600)) return true
|
|
88
|
+
|
|
89
|
+
// 4xx client errors -> do not retry
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Fallback to your existing heuristics (gRPC codes, node syscodes, etc.)
|
|
94
|
+
return isRetryableDefault(err)
|
|
95
|
+
}
|
|
96
|
+
|
|
71
97
|
export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
|
|
72
98
|
const retries = opts.retries ?? 4
|
|
73
99
|
const base = opts.baseDelayMs ?? 200
|
|
@@ -82,7 +108,8 @@ export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): P
|
|
|
82
108
|
let attempt = 0
|
|
83
109
|
let lastErr: any
|
|
84
110
|
|
|
85
|
-
|
|
111
|
+
// attempt = number of retries already performed; initial try is attempt==0
|
|
112
|
+
while (attempt <= retries) {
|
|
86
113
|
if (signal?.aborted) throw new Error('Aborted')
|
|
87
114
|
|
|
88
115
|
try {
|
|
@@ -94,13 +121,11 @@ export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): P
|
|
|
94
121
|
throw err
|
|
95
122
|
}
|
|
96
123
|
|
|
97
|
-
if (attempt >= retries)
|
|
98
|
-
break
|
|
99
|
-
}
|
|
124
|
+
if (attempt >= retries) break
|
|
100
125
|
|
|
126
|
+
// compute next retry (1-based)
|
|
101
127
|
const nextAttempt = attempt + 1
|
|
102
|
-
|
|
103
|
-
const exp = Math.min(max, base * Math.pow(2, attempt))
|
|
128
|
+
const exp = Math.min(max, base * Math.pow(2, nextAttempt - 1))
|
|
104
129
|
const delay = Math.floor(Math.random() * exp)
|
|
105
130
|
|
|
106
131
|
if (typeof timeoutMs === 'number') {
|
|
@@ -111,14 +136,13 @@ export async function retry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): P
|
|
|
111
136
|
}
|
|
112
137
|
|
|
113
138
|
try {
|
|
139
|
+
// pass 1-based attempt to onRetry to mean "this is the upcoming retry number"
|
|
114
140
|
onRetry?.(err, nextAttempt, delay)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// ignore errors from onRetry
|
|
141
|
+
} catch {
|
|
142
|
+
/* ignore onRetry errors */
|
|
118
143
|
}
|
|
119
144
|
|
|
120
145
|
await sleep(delay, signal)
|
|
121
|
-
|
|
122
146
|
attempt = nextAttempt
|
|
123
147
|
}
|
|
124
148
|
}
|
package/src/task.ts
CHANGED
|
@@ -1,7 +1,39 @@
|
|
|
1
|
+
// src/cloud-tasks.ts
|
|
1
2
|
import { CloudTasksClient, protos } from '@google-cloud/tasks'
|
|
3
|
+
import { retry, isRetryableDefault, RetryOptions } from './retry'
|
|
2
4
|
|
|
3
5
|
const tasksClient = new CloudTasksClient()
|
|
4
6
|
|
|
7
|
+
export type CreateTaskOpts = {
|
|
8
|
+
// optional deterministic id to make createTask idempotent
|
|
9
|
+
taskId?: string
|
|
10
|
+
retryOptions?: RetryOptions
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// cloud-tasks errors can be gRPC-style (numeric code) or an Error with code string.
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
|
+
export function isRetryableCloudTasks(err: any): boolean {
|
|
16
|
+
if (!err) return false
|
|
17
|
+
|
|
18
|
+
// If error explicitly says ALREADY_EXISTS, do not retry (client already has the task)
|
|
19
|
+
// gRPC ALREADY_EXISTS numeric code is 6
|
|
20
|
+
if (
|
|
21
|
+
err?.code === 6 ||
|
|
22
|
+
err?.grpcCode === 6 ||
|
|
23
|
+
String(err?.code).toUpperCase().includes('ALREADY_EXISTS')
|
|
24
|
+
) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Otherwise delegate to your general retryable heuristics (network / 14=UNAVAILABLE / 5xx etc.)
|
|
29
|
+
return isRetryableDefault(err)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a Cloud Task with retry logic.
|
|
34
|
+
* If opts.taskId is provided, the task name will be parent + '/tasks/' + taskId — this
|
|
35
|
+
* makes the operation idempotent (create will fail with ALREADY_EXISTS if it already exists).
|
|
36
|
+
*/
|
|
5
37
|
export async function createTask(
|
|
6
38
|
projectId: string,
|
|
7
39
|
region: string,
|
|
@@ -9,7 +41,8 @@ export async function createTask(
|
|
|
9
41
|
data: unknown,
|
|
10
42
|
serviceAccount: string,
|
|
11
43
|
audience: string,
|
|
12
|
-
delaySeconds: number = 0
|
|
44
|
+
delaySeconds: number = 0,
|
|
45
|
+
opts: CreateTaskOpts = {}
|
|
13
46
|
): Promise<string> {
|
|
14
47
|
if (!projectId || !region || !queueId || !serviceAccount || !audience) {
|
|
15
48
|
throw new Error('Missing required parameters for Cloud Tasks setup')
|
|
@@ -25,6 +58,7 @@ export async function createTask(
|
|
|
25
58
|
}
|
|
26
59
|
: undefined
|
|
27
60
|
|
|
61
|
+
// Build the base task
|
|
28
62
|
const task: protos.google.cloud.tasks.v2.ITask = {
|
|
29
63
|
httpRequest: {
|
|
30
64
|
httpMethod: protos.google.cloud.tasks.v2.HttpMethod.POST,
|
|
@@ -41,11 +75,57 @@ export async function createTask(
|
|
|
41
75
|
scheduleTime: scheduledTime
|
|
42
76
|
}
|
|
43
77
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
// Optionally set a deterministic name to make task creation idempotent.
|
|
79
|
+
// The name must be full resource path: projects/PROJECT/locations/REGION/queues/QUEUE/tasks/TASK_ID
|
|
80
|
+
if (opts.taskId) {
|
|
81
|
+
// sanitize/validate taskId if you want; Cloud Tasks requires certain name rules.
|
|
82
|
+
task.name = `${parent}/tasks/${opts.taskId}`
|
|
47
83
|
}
|
|
48
84
|
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
// retry options with sensible defaults; allow caller to override via opts.retryOptions
|
|
86
|
+
const retryOpts: RetryOptions = {
|
|
87
|
+
retries: 3,
|
|
88
|
+
baseDelayMs: 200,
|
|
89
|
+
maxDelayMs: 3000,
|
|
90
|
+
isRetryable: isRetryableCloudTasks,
|
|
91
|
+
onRetry: (err, attempt, delay) => {
|
|
92
|
+
console.warn(`createTask retry #${attempt} in ${delay}ms — ${String(err?.message ?? err)}`)
|
|
93
|
+
},
|
|
94
|
+
...opts.retryOptions // allow overrides
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// the unit-of-work we want to retry
|
|
98
|
+
const doCreate = async (): Promise<string> => {
|
|
99
|
+
const [response] = await tasksClient.createTask({ parent, task })
|
|
100
|
+
if (!response?.name) {
|
|
101
|
+
// unexpected response shape — treat as non-retryable final failure
|
|
102
|
+
throw new Error('Failed to create task: no name returned')
|
|
103
|
+
}
|
|
104
|
+
return response.name
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const name = await retry(() => doCreate(), retryOpts)
|
|
109
|
+
console.log(`✅ Created Cloud Task: ${name}`)
|
|
110
|
+
return name
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
// If ALREADY_EXISTS happened (and was not considered retryable), you may prefer to
|
|
114
|
+
// return the constructed task name (if provided) rather than null/throw.
|
|
115
|
+
if (opts.taskId) {
|
|
116
|
+
const expectedName = `${parent}/tasks/${opts.taskId}`
|
|
117
|
+
// If we got ALREADY_EXISTS, treat as success and return existing name
|
|
118
|
+
if (
|
|
119
|
+
err?.original?.code === 6 ||
|
|
120
|
+
err?.code === 6 ||
|
|
121
|
+
String(err?.original?.code).toUpperCase().includes('ALREADY_EXISTS')
|
|
122
|
+
) {
|
|
123
|
+
console.warn('createTask — task already exists, returning existing name', expectedName)
|
|
124
|
+
return expectedName
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.error('createTask — final failure after retries', { error: err?.message ?? err })
|
|
129
|
+
throw err // rethrow (or return null if you prefer)
|
|
130
|
+
}
|
|
51
131
|
}
|
package/src/tw/wallet.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import axios from 'axios'
|
|
2
2
|
import { getSecret } from '../secrets'
|
|
3
|
+
import { isRetryableAxios, retry, RetryOptions } from '../retry'
|
|
3
4
|
|
|
4
5
|
export async function pregenerateInAppWallet(
|
|
5
6
|
projectId: string,
|
|
@@ -8,9 +9,13 @@ export async function pregenerateInAppWallet(
|
|
|
8
9
|
email: string
|
|
9
10
|
): Promise<string | null> {
|
|
10
11
|
const TW_SECRET_KEY = await getSecret(projectId, twSecretKeyName)
|
|
12
|
+
if (!TW_SECRET_KEY) {
|
|
13
|
+
console.error('Missing TW secret key', { projectId, twSecretKeyName })
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
const
|
|
17
|
+
const doRequest = async (): Promise<string> => {
|
|
18
|
+
const res = await axios.post(
|
|
14
19
|
'https://in-app-wallet.thirdweb.com/api/v1/pregenerate',
|
|
15
20
|
{ strategy: 'email', email },
|
|
16
21
|
{
|
|
@@ -19,20 +24,45 @@ export async function pregenerateInAppWallet(
|
|
|
19
24
|
'x-client-id': twClientId,
|
|
20
25
|
'Content-Type': 'application/json'
|
|
21
26
|
},
|
|
22
|
-
timeout:
|
|
27
|
+
timeout: 10000
|
|
23
28
|
}
|
|
24
29
|
)
|
|
25
30
|
|
|
26
|
-
const wallet =
|
|
31
|
+
const wallet = res.data?.wallet
|
|
27
32
|
if (!wallet || !wallet.address) {
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
// server returned 2xx but unexpected shape — don't retry, treat as final failure
|
|
34
|
+
console.error('Invalid wallet response shape', { status: res.status, data: res.data })
|
|
35
|
+
throw new Error('Invalid wallet response')
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
console.log('Wallet pregeneration response:', wallet.address)
|
|
33
38
|
return wallet.address
|
|
34
|
-
}
|
|
35
|
-
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const opts: RetryOptions = {
|
|
42
|
+
retries: 3,
|
|
43
|
+
baseDelayMs: 300,
|
|
44
|
+
maxDelayMs: 2000,
|
|
45
|
+
timeoutMs: 15000,
|
|
46
|
+
isRetryable: isRetryableAxios,
|
|
47
|
+
onRetry: (err, attempt, delay) => {
|
|
48
|
+
console.warn(
|
|
49
|
+
`pregenerateInAppWallet: retry #${attempt} in ${delay}ms — ${String(err?.message)}`
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const address = await retry(() => doRequest(), opts)
|
|
56
|
+
return address
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
// final failure after retries
|
|
60
|
+
console.error('pregenerateInAppWallet: final failure', {
|
|
61
|
+
message: err?.message,
|
|
62
|
+
// include axios response data when possible
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
64
|
+
responseData: err?.response?.data ?? err?.cause ?? (err as any).original ?? null
|
|
65
|
+
})
|
|
36
66
|
return null
|
|
37
67
|
}
|
|
38
68
|
}
|
package/tests/retry.test.ts
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
// tests/retry.test.ts
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
import axios from 'axios'
|
|
5
|
+
import { retry, isRetryableDefault, sleep, isRetryableAxios } from '../src'
|
|
6
|
+
|
|
7
|
+
describe('sleep()', () => {
|
|
8
|
+
const realAbortController = global.AbortController
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
// restore timers and AbortController if changed
|
|
12
|
+
jest.useRealTimers()
|
|
13
|
+
global.AbortController = realAbortController
|
|
14
|
+
jest.clearAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('resolves after the specified delay', async () => {
|
|
18
|
+
jest.useFakeTimers()
|
|
19
|
+
const p = sleep(100)
|
|
20
|
+
|
|
21
|
+
// advance timers by the sleep duration
|
|
22
|
+
jest.advanceTimersByTime(100)
|
|
23
|
+
|
|
24
|
+
// allow the Promise microtask queue to run
|
|
25
|
+
await Promise.resolve()
|
|
26
|
+
|
|
27
|
+
await expect(p).resolves.toBeUndefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('rejects immediately if signal is already aborted', async () => {
|
|
31
|
+
const ac = new AbortController()
|
|
32
|
+
ac.abort()
|
|
33
|
+
|
|
34
|
+
await expect(sleep(50, ac.signal)).rejects.toThrow('Aborted')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('rejects when aborted after scheduling', async () => {
|
|
38
|
+
jest.useFakeTimers()
|
|
39
|
+
const ac = new AbortController()
|
|
40
|
+
|
|
41
|
+
const p = sleep(1000, ac.signal)
|
|
42
|
+
|
|
43
|
+
// abort after the sleep has been scheduled but before timeout
|
|
44
|
+
ac.abort()
|
|
45
|
+
|
|
46
|
+
// allow the event-loop microtasks to run
|
|
47
|
+
await Promise.resolve()
|
|
48
|
+
|
|
49
|
+
await expect(p).rejects.toThrow('Aborted')
|
|
50
|
+
|
|
51
|
+
// advancing timers should not resolve the promise
|
|
52
|
+
jest.advanceTimersByTime(1000)
|
|
53
|
+
await Promise.resolve()
|
|
54
|
+
await expect(p).rejects.toThrow('Aborted')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
4
57
|
|
|
5
58
|
describe('isRetryableDefault', () => {
|
|
6
59
|
test('returns false for falsy errors (covers `if (!err) return false`)', () => {
|
|
@@ -53,6 +106,71 @@ describe('isRetryableDefault', () => {
|
|
|
53
106
|
})
|
|
54
107
|
})
|
|
55
108
|
|
|
109
|
+
describe('isRetryableAxios()', () => {
|
|
110
|
+
// Helper to synthesize axios-like errors
|
|
111
|
+
function makeAxiosError(payload: Partial<any> = {}) {
|
|
112
|
+
// minimal axios-style error: flag + response optionally
|
|
113
|
+
const err: any = new Error(payload.message ?? 'axios-err')
|
|
114
|
+
err.isAxiosError = true
|
|
115
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'response')) {
|
|
116
|
+
err.response = payload.response
|
|
117
|
+
} else if (payload.response === undefined && payload.noResponse) {
|
|
118
|
+
// explicit network/no-response: leave out response
|
|
119
|
+
}
|
|
120
|
+
if (payload.code) err.code = payload.code
|
|
121
|
+
if (payload.message) err.message = payload.message
|
|
122
|
+
return err
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test('returns false when err is falsy', () => {
|
|
126
|
+
expect(isRetryableAxios(null)).toBe(false)
|
|
127
|
+
expect(isRetryableAxios(undefined)).toBe(false)
|
|
128
|
+
// also check other falsy-ish values that shouldn't be considered retryable
|
|
129
|
+
expect(isRetryableAxios(false as any)).toBe(false)
|
|
130
|
+
expect(isRetryableAxios('' as any)).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('returns true for network/no-response axios errors', () => {
|
|
134
|
+
const networkErr = makeAxiosError({ noResponse: true })
|
|
135
|
+
expect(axios.isAxiosError(networkErr)).toBe(true) // sanity check
|
|
136
|
+
expect(isRetryableAxios(networkErr)).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('returns true for 500 server error', () => {
|
|
140
|
+
const serverErr = makeAxiosError({ response: { status: 500, data: {} } })
|
|
141
|
+
expect(isRetryableAxios(serverErr)).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('returns true for 503 server error', () => {
|
|
145
|
+
const serverErr = makeAxiosError({ response: { status: 503, data: {} } })
|
|
146
|
+
expect(isRetryableAxios(serverErr)).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('returns true for 429 rate limit', () => {
|
|
150
|
+
const rateErr = makeAxiosError({ response: { status: 429, data: {} } })
|
|
151
|
+
expect(isRetryableAxios(rateErr)).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('returns false for 400 client error (do not retry)', () => {
|
|
155
|
+
const badReq = makeAxiosError({ response: { status: 400, data: {} } })
|
|
156
|
+
expect(isRetryableAxios(badReq)).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('delegates to isRetryableDefault for non-axios errors', () => {
|
|
160
|
+
// make a network-style Node error that isRetryableDefault recognizes
|
|
161
|
+
const nodeErr = { code: 'ECONNRESET', message: 'socket closed' }
|
|
162
|
+
expect(isRetryableDefault(nodeErr)).toBe(true) // sanity
|
|
163
|
+
expect(isRetryableAxios(nodeErr)).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('returns false for non-retryable non-axios error', () => {
|
|
167
|
+
const normalErr = new Error('something bad but not retryable')
|
|
168
|
+
// isRetryableDefault likely returns false for a plain Error w/o hint
|
|
169
|
+
expect(isRetryableDefault(normalErr)).toBe(false)
|
|
170
|
+
expect(isRetryableAxios(normalErr)).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
56
174
|
describe('retry util', () => {
|
|
57
175
|
afterEach(() => {
|
|
58
176
|
jest.restoreAllMocks()
|
package/tests/task.test.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// tests/createTask.retry.test.ts
|
|
1
3
|
const mockCreateTask = jest.fn()
|
|
2
4
|
const mockQueuePath = jest.fn(
|
|
3
|
-
(projectId, region, queueId) =>
|
|
5
|
+
(projectId: string, region: string, queueId: string) =>
|
|
6
|
+
`projects/${projectId}/locations/${region}/queues/${queueId}`
|
|
4
7
|
)
|
|
5
8
|
|
|
9
|
+
// Mock Cloud Tasks client and protos (same as your original file)
|
|
6
10
|
jest.mock('@google-cloud/tasks', () => {
|
|
7
11
|
return {
|
|
8
12
|
CloudTasksClient: jest.fn(() => ({
|
|
@@ -25,87 +29,161 @@ jest.mock('@google-cloud/tasks', () => {
|
|
|
25
29
|
}
|
|
26
30
|
})
|
|
27
31
|
|
|
28
|
-
|
|
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
|
|
29
107
|
|
|
30
|
-
describe('createTask', () => {
|
|
31
108
|
beforeEach(() => {
|
|
32
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(() => {})
|
|
33
114
|
})
|
|
34
115
|
|
|
35
116
|
it('throws error if any required parameter is missing', async () => {
|
|
36
|
-
//
|
|
117
|
+
// Ensure retry / createTask are not called for parameter validation errors
|
|
37
118
|
await expect(
|
|
38
119
|
createTask('', 'region', 'queue', {}, 'serviceAccount', 'audience')
|
|
39
120
|
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
40
121
|
|
|
41
|
-
// Missing region
|
|
42
122
|
await expect(
|
|
43
123
|
createTask('project', '', 'queue', {}, 'serviceAccount', 'audience')
|
|
44
124
|
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
45
125
|
|
|
46
|
-
// Missing queueId
|
|
47
126
|
await expect(
|
|
48
127
|
createTask('project', 'region', '', {}, 'serviceAccount', 'audience')
|
|
49
128
|
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
50
129
|
|
|
51
|
-
// Missing serviceAccount
|
|
52
130
|
await expect(createTask('project', 'region', 'queue', {}, '', 'audience')).rejects.toThrow(
|
|
53
131
|
'Missing required parameters for Cloud Tasks setup'
|
|
54
132
|
)
|
|
55
133
|
|
|
56
|
-
// Missing audience
|
|
57
134
|
await expect(
|
|
58
135
|
createTask('project', 'region', 'queue', {}, 'serviceAccount', '')
|
|
59
136
|
).rejects.toThrow('Missing required parameters for Cloud Tasks setup')
|
|
137
|
+
|
|
138
|
+
expect(mockCreateTask).not.toHaveBeenCalled()
|
|
139
|
+
expect(mockRetry).not.toHaveBeenCalled()
|
|
60
140
|
})
|
|
61
141
|
|
|
62
|
-
it('should create a task and return task name', async () => {
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
|
|
65
148
|
mockCreateTask.mockResolvedValue([{ name: mockTaskName }])
|
|
66
149
|
|
|
67
|
-
const result = await createTask(
|
|
68
|
-
'test-project',
|
|
69
|
-
'us-central1',
|
|
70
|
-
'test-queue',
|
|
71
|
-
{ test: 'data' },
|
|
72
|
-
'test-sa@test.iam.gserviceaccount.com',
|
|
73
|
-
'https://run-url'
|
|
74
|
-
)
|
|
150
|
+
const result = await createTask(projectId, region, queueId, data, serviceAccount, audience)
|
|
75
151
|
|
|
76
152
|
expect(result).toBe(mockTaskName)
|
|
77
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')
|
|
78
158
|
})
|
|
79
159
|
|
|
80
160
|
it('should throw error if task name is missing', async () => {
|
|
161
|
+
mockRetry.mockImplementation(async (fn: () => any) => {
|
|
162
|
+
return await fn()
|
|
163
|
+
})
|
|
81
164
|
mockCreateTask.mockResolvedValue([{}]) // Simulate missing name
|
|
82
165
|
|
|
83
166
|
await expect(
|
|
84
|
-
createTask(
|
|
85
|
-
'test-project',
|
|
86
|
-
'us-central1',
|
|
87
|
-
'test-queue',
|
|
88
|
-
{ foo: 'bar' },
|
|
89
|
-
'test@project.iam.gserviceaccount.com',
|
|
90
|
-
'https://example.com'
|
|
91
|
-
)
|
|
167
|
+
createTask(projectId, region, queueId, { foo: 'bar' }, serviceAccount, audience)
|
|
92
168
|
).rejects.toThrow('Failed to create task: no name returned')
|
|
93
169
|
})
|
|
94
170
|
|
|
95
171
|
it('should include scheduleTime if delaySeconds is set', async () => {
|
|
96
|
-
|
|
97
|
-
mockCreateTask.mockResolvedValue([
|
|
172
|
+
mockRetry.mockImplementation(async (fn: () => any) => await fn())
|
|
173
|
+
mockCreateTask.mockResolvedValue([
|
|
174
|
+
{ name: 'projects/test/locations/us-central1/queues/test/tasks/task-456' }
|
|
175
|
+
])
|
|
98
176
|
|
|
99
177
|
const delaySeconds = 120
|
|
100
178
|
const before = Math.floor(Date.now() / 1000) + delaySeconds
|
|
101
179
|
|
|
102
180
|
await createTask(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
181
|
+
projectId,
|
|
182
|
+
region,
|
|
183
|
+
queueId,
|
|
106
184
|
{ message: 'delayed' },
|
|
107
|
-
|
|
108
|
-
|
|
185
|
+
serviceAccount,
|
|
186
|
+
audience,
|
|
109
187
|
delaySeconds
|
|
110
188
|
)
|
|
111
189
|
|
|
@@ -118,4 +196,142 @@ describe('createTask', () => {
|
|
|
118
196
|
expect(scheduleTime).toBeGreaterThanOrEqual(before)
|
|
119
197
|
expect(scheduleTime).toBeLessThanOrEqual(after)
|
|
120
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
|
+
})
|
|
121
337
|
})
|
package/tests/tw/wallet.test.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// tests/wallet.test.ts (or append to your existing test file)
|
|
1
3
|
import axios from 'axios'
|
|
2
4
|
import { pregenerateInAppWallet } from '../../src/tw/wallet'
|
|
3
5
|
import * as secrets from '../../src/secrets'
|
|
@@ -8,7 +10,27 @@ jest.mock('../../src/secrets')
|
|
|
8
10
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
|
9
11
|
const mockedGetSecret = secrets.getSecret as jest.Mock
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
const makeSuccessResponse = (data: any) => ({ data, status: 200 })
|
|
14
|
+
|
|
15
|
+
// small helper to synthesize axios-like errors
|
|
16
|
+
function makeAxiosError(message: string, status?: number, noResponse = false) {
|
|
17
|
+
const err: any = new Error(message)
|
|
18
|
+
err.isAxiosError = true
|
|
19
|
+
if (!noResponse) {
|
|
20
|
+
if (typeof status === 'number') {
|
|
21
|
+
err.response = { status, data: { message: `mocked ${status}` } }
|
|
22
|
+
} else {
|
|
23
|
+
// no numeric status provided but simulate a response
|
|
24
|
+
err.response = { status: 500, data: { message: 'mock' } }
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
// explicitly simulate network error: no response property
|
|
28
|
+
delete err.response
|
|
29
|
+
}
|
|
30
|
+
return err
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('pregenerateInAppWallet with retry behavior', () => {
|
|
12
34
|
const mockProjectId = 'test-project'
|
|
13
35
|
const mockClientId = 'test-client-id'
|
|
14
36
|
const mockSecretKeyName = 'test-key'
|
|
@@ -16,19 +38,66 @@ describe('pregenerateInAppWallet', () => {
|
|
|
16
38
|
const mockWalletAddress = '0xabc123'
|
|
17
39
|
const mockSecretValue = 'mock-secret-key'
|
|
18
40
|
|
|
41
|
+
const realMathRandom = Math.random
|
|
42
|
+
const realWarn = console.warn
|
|
43
|
+
const realError = console.error
|
|
44
|
+
|
|
19
45
|
beforeEach(() => {
|
|
20
46
|
jest.clearAllMocks()
|
|
47
|
+
// make retry jitter deterministic (so delay becomes 0)
|
|
48
|
+
Math.random = jest.fn(() => 0)
|
|
49
|
+
|
|
50
|
+
jest
|
|
51
|
+
.spyOn(axios, 'isAxiosError')
|
|
52
|
+
.mockImplementation((e: any) => Boolean(e && e.isAxiosError) as any)
|
|
53
|
+
// silence console.warn/error in passing tests (but still allow spying)
|
|
54
|
+
console.warn = jest.fn()
|
|
55
|
+
console.error = jest.fn()
|
|
21
56
|
})
|
|
22
57
|
|
|
23
|
-
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
// restore
|
|
60
|
+
Math.random = realMathRandom
|
|
61
|
+
console.warn = realWarn
|
|
62
|
+
console.error = realError
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns null and logs if secret is missing', async () => {
|
|
66
|
+
// simulate secret manager returning nothing
|
|
67
|
+
mockedGetSecret.mockResolvedValueOnce(undefined)
|
|
68
|
+
|
|
69
|
+
const result = await pregenerateInAppWallet(
|
|
70
|
+
mockProjectId,
|
|
71
|
+
mockClientId,
|
|
72
|
+
mockSecretKeyName,
|
|
73
|
+
mockEmail
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// should not call axios at all
|
|
77
|
+
expect(mockedAxios.post).not.toHaveBeenCalled()
|
|
78
|
+
expect(result).toBeNull()
|
|
79
|
+
|
|
80
|
+
// console.error should be called with "Missing TW secret key"
|
|
81
|
+
expect((console.error as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
82
|
+
expect((console.error as jest.Mock).mock.calls[0][0]).toEqual('Missing TW secret key')
|
|
83
|
+
// second arg should contain the projectId and secretName
|
|
84
|
+
expect((console.error as jest.Mock).mock.calls[0][1]).toEqual(
|
|
85
|
+
expect.objectContaining({ projectId: mockProjectId, twSecretKeyName: mockSecretKeyName })
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('retries on 500 then succeeds and returns address', async () => {
|
|
24
90
|
mockedGetSecret.mockResolvedValue(mockSecretValue)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
91
|
+
|
|
92
|
+
// first call: server 500 -> rejected
|
|
93
|
+
mockedAxios.post
|
|
94
|
+
.mockRejectedValueOnce(makeAxiosError('server error', 500))
|
|
95
|
+
// second call: success
|
|
96
|
+
.mockResolvedValueOnce({
|
|
97
|
+
data: {
|
|
98
|
+
wallet: { address: mockWalletAddress }
|
|
29
99
|
}
|
|
30
|
-
}
|
|
31
|
-
})
|
|
100
|
+
} as any)
|
|
32
101
|
|
|
33
102
|
const result = await pregenerateInAppWallet(
|
|
34
103
|
mockProjectId,
|
|
@@ -38,29 +107,55 @@ describe('pregenerateInAppWallet', () => {
|
|
|
38
107
|
)
|
|
39
108
|
|
|
40
109
|
expect(mockedGetSecret).toHaveBeenCalledWith(mockProjectId, mockSecretKeyName)
|
|
41
|
-
expect(mockedAxios.post).
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
110
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(2)
|
|
111
|
+
expect(result).toBe(mockWalletAddress)
|
|
112
|
+
// onRetry should have been called (we log via console.warn in our version)
|
|
113
|
+
expect((console.warn as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does NOT retry on 400 and returns null immediately', async () => {
|
|
117
|
+
mockedGetSecret.mockResolvedValue(mockSecretValue)
|
|
118
|
+
mockedAxios.post.mockRejectedValue(makeAxiosError('bad request', 400))
|
|
119
|
+
|
|
120
|
+
const result = await pregenerateInAppWallet(
|
|
121
|
+
mockProjectId,
|
|
122
|
+
mockClientId,
|
|
123
|
+
mockSecretKeyName,
|
|
124
|
+
mockEmail
|
|
52
125
|
)
|
|
53
126
|
|
|
127
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
|
|
128
|
+
expect(result).toBeNull()
|
|
129
|
+
// console.warn should not indicate retry
|
|
130
|
+
expect((console.warn as jest.Mock).mock.calls.length).toBe(0)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('retries on network/no-response errors and then succeeds', async () => {
|
|
134
|
+
mockedGetSecret.mockResolvedValue(mockSecretValue)
|
|
135
|
+
|
|
136
|
+
// First: network error (no response)
|
|
137
|
+
mockedAxios.post
|
|
138
|
+
.mockRejectedValueOnce(makeAxiosError('network down', undefined, true))
|
|
139
|
+
.mockResolvedValueOnce({
|
|
140
|
+
data: { wallet: { address: mockWalletAddress } }
|
|
141
|
+
} as any)
|
|
142
|
+
|
|
143
|
+
const result = await pregenerateInAppWallet(
|
|
144
|
+
mockProjectId,
|
|
145
|
+
mockClientId,
|
|
146
|
+
mockSecretKeyName,
|
|
147
|
+
mockEmail
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(2)
|
|
54
151
|
expect(result).toBe(mockWalletAddress)
|
|
55
152
|
})
|
|
56
153
|
|
|
57
|
-
it('
|
|
154
|
+
it('exhausts retries on repeated 500s and returns null', async () => {
|
|
58
155
|
mockedGetSecret.mockResolvedValue(mockSecretValue)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
})
|
|
156
|
+
|
|
157
|
+
// Always 500 errors
|
|
158
|
+
mockedAxios.post.mockRejectedValue(makeAxiosError('server error', 500))
|
|
64
159
|
|
|
65
160
|
const result = await pregenerateInAppWallet(
|
|
66
161
|
mockProjectId,
|
|
@@ -69,12 +164,19 @@ describe('pregenerateInAppWallet', () => {
|
|
|
69
164
|
mockEmail
|
|
70
165
|
)
|
|
71
166
|
|
|
167
|
+
// retry attempts are implementation-dependent (your retry opts). We assert >1 calls
|
|
168
|
+
expect(mockedAxios.post.mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
169
|
+
// final result should be null when retries exhausted
|
|
72
170
|
expect(result).toBeNull()
|
|
171
|
+
// error should have been logged
|
|
172
|
+
expect((console.error as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
73
173
|
})
|
|
74
174
|
|
|
75
|
-
it('returns null
|
|
76
|
-
mockedGetSecret.
|
|
77
|
-
|
|
175
|
+
it('logs invalid wallet shape and returns null when wallet.address is missing (2xx response)', async () => {
|
|
176
|
+
mockedGetSecret.mockResolvedValueOnce(mockSecretValue)
|
|
177
|
+
|
|
178
|
+
// Simulate a 200 response that has wallet but no address
|
|
179
|
+
mockedAxios.post.mockResolvedValueOnce(makeSuccessResponse({ wallet: {} }))
|
|
78
180
|
|
|
79
181
|
const result = await pregenerateInAppWallet(
|
|
80
182
|
mockProjectId,
|
|
@@ -83,6 +185,22 @@ describe('pregenerateInAppWallet', () => {
|
|
|
83
185
|
mockEmail
|
|
84
186
|
)
|
|
85
187
|
|
|
188
|
+
// axios was called once, but the function returns null (final failure)
|
|
189
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
|
|
86
190
|
expect(result).toBeNull()
|
|
191
|
+
|
|
192
|
+
// console.error should be called with the shape error message
|
|
193
|
+
expect((console.error as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
|
|
194
|
+
|
|
195
|
+
// the first arg of the logged call should be the string we log in the implementation
|
|
196
|
+
// (adjust if your message differs)
|
|
197
|
+
const firstArg = (console.error as jest.Mock).mock.calls[0][0]
|
|
198
|
+
expect(firstArg).toEqual(
|
|
199
|
+
'Invalid wallet response shape' /* or 'Invalid wallet response shape' */
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// the second arg should contain status and data info
|
|
203
|
+
const secondArg = (console.error as jest.Mock).mock.calls[0][1]
|
|
204
|
+
expect(secondArg).toEqual(expect.objectContaining({ status: 200, data: { wallet: {} } }))
|
|
87
205
|
})
|
|
88
206
|
})
|