@boodibox/api-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SC0d3r
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # @boodibox/api-client
2
+
3
+ این بسته برای **ارسال خودکار پست‌ها به `https://boodibox.com/posts`** طراحی شده — یعنی شما می‌توانید به‌سرعت پست منتشر کنید یا یک بات بسازید که محتوای شما را خودکار ارسال کند.
4
+ کتابخانه جریان کامل را پوشش می‌دهد: آپلود تصویر (اختیاری) → نظارت روی وضعیت تا زمانی که پردازش شود → ارسال پست با `medias` که سرور تولید می‌کند.
5
+
6
+ **آموزش گرفتن API Key:** https://boodibox.com/dev/api-key
7
+
8
+
9
+ ## نصب
10
+ ```bash
11
+ npm install @boodibox/api-client
12
+ # یا
13
+ yarn add @boodibox/api-client
14
+ ````
15
+
16
+ > این کتابخانه از امکانات بومی Node (fetch, FormData, Blob) استفاده می‌کند. از Node >= 18 استفاده کنید، یا برای محیط‌هایی که `FormData` / `Blob` ندارند یک polyfill نصب کنید (مثلاً `formdata-node`).
17
+
18
+ ---
19
+
20
+
21
+ این بسته برای خودکارسازی ارسال پست‌ها به آدرس https://boodibox.com/posts ساخته شده است. شما می‌توانید:
22
+
23
+ * به‌راحتی پست منتشر کنید، یا
24
+ * یک ربات بسازید که محتوا را خودکار ارسال کند.
25
+
26
+ تذکرهای مهم:
27
+
28
+ * پارامتر `replyPermission` **فقط** دو مقدار معتبر دارد: `PRIVATE` یا `PUBLIC`. (در غیر این صورت کتابخانه خطا پرتاب می‌کند.)
29
+ * اگر می‌خواهید یک پست را به عنوان نقل قول ارسال کنید، مقدار `quotePostID` باید یک آی‌دی معتبر **CUID v2** باشد (پترن عملی: حرف اول، سپس حروف و اعداد کوچک، طول معمول 24–32 کاراکتر).
30
+
31
+ ---
32
+
33
+ ## استفادهٔ سریع
34
+
35
+ ```js
36
+ const createClient = require('@boodibox/api-client');
37
+
38
+ const client = createClient({
39
+ apiKey: 'YOUR_API_KEY_HERE' // می‌تواند فقط توکن یا "Bearer <token>" باشد
40
+ });
41
+
42
+ // ارسال پست ساده بدون تصویر
43
+ await client.submitPost({ body: 'سلام از API' });
44
+
45
+ // ارسال پست با تصویر (از دیسک)
46
+ await client.submitPostWithFiles({
47
+ body: 'پست با عکس',
48
+ files: [{ path: './tiny.jpg' }],
49
+ replyPermission: 'PUBLIC' // یا 'PRIVATE'
50
+ });
51
+ ```
52
+
53
+ ---
54
+
55
+ ## APIها (توابع اصلی)
56
+
57
+ * `createClient({ baseUrl, apiKey })` – ساخت کلاینت
58
+ * `uploadFiles(files)` – آپلود فایل‌ها (برمی‌گرداند آرایه‌ای از apiupload ids)
59
+ * `pollUntilProcessed(uploadId, options)` – نظارت تا وضعیت `PROCESSED`
60
+ * `submitPost({ body, medias, replyPermission, quotePostID, userIP })`
61
+ * `submitPostWithFiles({ body, files, replyPermission, quotePostID, pollOptions, timeoutMs })`
62
+
63
+ * این تابع به‌صورت convenience تمامی مراحل را انجام می‌دهد: آپلود فایل‌ها → poll تا پردازش → ارسال پست با `medias` برگشتی
64
+
65
+ ### شکل فایل‌ها (files)
66
+
67
+ هر عنصر می‌تواند یکی از موارد باشد:
68
+
69
+ * `{ path: "./file.jpg" }` — خواندن از دیسک
70
+ * `{ buffer: Buffer, filename: "a.jpg" }`
71
+ * `{ file: File }` (در مرورگر)
72
+
73
+ ---
74
+
75
+ ## اعتبارسنجی و محدودیت‌ها
76
+
77
+ * `replyPermission` تنها مقدارهای معتبر `PRIVATE` یا `PUBLIC` را می‌پذیرد.
78
+ * `quotePostID` باید شبیه **CUID v2** باشد (حرف اول، سپس حروف/اعداد کوچک؛ طول معمول 24–32). اگر نامعتبر باشد، خطا پرتاب می‌شود.
79
+ * کتابخانه تلاش‌هایی برای retry آپلود انجام می‌دهد و اگر سرور خطا بازگرداند، خطا با بدنهٔ پاسخ شامل جزئیات برمی‌گردد (`err.body`).
80
+
81
+ ---
82
+
83
+ ## تست‌ها (Bun)
84
+
85
+ برای اجرای تست‌های یکپارچه (integration) به صورت اختیاری از متغیرهای محیطی زیر استفاده کنید:
86
+
87
+ * `API_KEY` — مقدار API key (فرمت: `Bearer <token>` یا فقط توکن)
88
+ * `TEST_INTEGRATION=true` — اگر این مقدار تنظیم نشده باشد، تست یکپارچه نادیده گرفته می‌شود
89
+ * `BASE_URL` — آدرس سرور (مثلاً `http://localhost:3000` یا `https://boodibox.com`)
90
+
91
+ نمونه:
92
+
93
+ ```bash
94
+ export API_KEY="Bearer xxxxx"
95
+ export TEST_INTEGRATION=true
96
+ export BASE_URL="http://localhost:3000"
97
+ bun test
98
+ ```
99
+
100
+ ## لینک مفید
101
+ * راهنمای گرفتن API Key: [https://boodibox.com/dev/api-key](https://boodibox.com/dev/api-key)
102
+
103
+ * پست ها بودیباکس: [https://boodibox.com/posts](https://boodibox.com/posts)
104
+
105
+ ## License
106
+
107
+ MIT License
108
+
109
+ Copyright (c) 2026 BoodiBox
110
+
111
+ Permission is hereby granted, free of charge, to any person obtaining a copy
112
+ of this software and associated documentation files (the "Software"), to deal
113
+ in the Software without restriction, including without limitation the rights
114
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
115
+ copies of the Software, and to permit persons to whom the Software is
116
+ furnished to do so, subject to the following conditions:
117
+
118
+ The above copyright notice and this permission notice shall be included in all
119
+ copies or substantial portions of the Software.
120
+
121
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
122
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
123
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
124
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
125
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
126
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
127
+ SOFTWARE.
package/index.js ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * index.js
3
+ * BoodiBox API client (minimal, no deps)
4
+ *
5
+ * Changes:
6
+ * - replyPermission defaults to "PUBLIC" when omitted
7
+ * - explicitly empty or falsey replyPermission values are rejected
8
+ * - other behavior unchanged (upload -> poll -> submit)
9
+ */
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const { fetchWithTimeout } = require('./lib/http');
14
+
15
+ const DEFAULTS = {
16
+ baseUrl: process.env.BASE_URL || 'https://boodibox.com',
17
+ uploadPath: '/api/v1/uploads',
18
+ postsPath: '/api/v1/posts',
19
+ pollIntervalMs: 1000,
20
+ pollTimeoutMs: 30000,
21
+ maxRetries: 3
22
+ };
23
+
24
+ // Practical CUID v2-ish regex: starts with a letter, lowercase alnum, length 24..32
25
+ const CUID2_REGEX = /^[a-z][a-z0-9]{23,31}$/i;
26
+ const REPLY_PERMISSIONS = new Set(['PRIVATE', 'PUBLIC']);
27
+
28
+ function ensureFormDataSupport() {
29
+ if (typeof FormData === 'undefined' || typeof Blob === 'undefined') {
30
+ throw new Error('Global FormData/Blob not available. Run in Node >=18 or provide a polyfill (e.g. formdata-node).');
31
+ }
32
+ }
33
+
34
+ function normalizeApiKey(raw) {
35
+ if (!raw) return null;
36
+ if (raw.startsWith('Bearer ')) return raw;
37
+ return `Bearer ${raw}`;
38
+ }
39
+
40
+ /**
41
+ * Validate replyPermission:
42
+ * - If rp is undefined -> caller should handle defaulting (we default to PUBLIC upstream)
43
+ * - If rp is explicitly provided but falsey ('' / null / 0 / false) -> reject
44
+ * - Otherwise normalize and ensure it is PUBLIC or PRIVATE
45
+ */
46
+ function validateReplyPermission(rp) {
47
+ if (rp === undefined) return; // caller handles default
48
+ if (rp === null || (typeof rp === 'string' && rp.trim() === '') || !rp) {
49
+ throw new Error('replyPermission cannot be empty. Use "PUBLIC" or "PRIVATE".');
50
+ }
51
+ const up = String(rp).toUpperCase();
52
+ if (!REPLY_PERMISSIONS.has(up)) {
53
+ throw new Error(`replyPermission must be one of: ${Array.from(REPLY_PERMISSIONS).join(', ')}`);
54
+ }
55
+ return up;
56
+ }
57
+
58
+ function validateQuotePostID(id) {
59
+ if (id == null) return;
60
+ const s = String(id).trim();
61
+ if (!CUID2_REGEX.test(s)) {
62
+ throw new Error('quotePostID must be a valid CUID v2-style id (letter + lowercase alphanumeric, length ~24-32).');
63
+ }
64
+ return s.toLowerCase();
65
+ }
66
+
67
+ function createClient(opts = {}) {
68
+ const config = { ...DEFAULTS, ...opts };
69
+ if (!config.apiKey) throw new Error('apiKey is required to create BoodiBox client');
70
+ const authHeader = normalizeApiKey(config.apiKey);
71
+
72
+ ensureFormDataSupport();
73
+
74
+ async function _doFetch(url, options = {}, timeoutMs = 15000) {
75
+ const headers = Object.assign({}, options.headers || {}, {
76
+ Authorization: authHeader,
77
+ Accept: 'application/json'
78
+ });
79
+ return fetchWithTimeout(url, { ...options, headers }, timeoutMs);
80
+ }
81
+
82
+ /**
83
+ * uploadFiles
84
+ * Accepts files array items:
85
+ * - { path }
86
+ * - { buffer, filename?, contentType? }
87
+ * - { file } (File)
88
+ *
89
+ * Uses File when available (Bun/browser) to ensure filename is sent correctly.
90
+ */
91
+ async function uploadFiles(files = []) {
92
+ if (!Array.isArray(files) || files.length === 0) throw new Error('files must be a non-empty array');
93
+ const url = new URL(config.uploadPath, config.baseUrl).toString();
94
+
95
+ const form = new FormData();
96
+ for (const f of files) {
97
+ if (f.path) {
98
+ const filename = f.filename || path.basename(f.path);
99
+ const buffer = fs.readFileSync(f.path);
100
+ const contentType = f.contentType || guessContentTypeFromFilename(filename) || 'application/octet-stream';
101
+
102
+ if (typeof File !== 'undefined') {
103
+ const fileObj = new File([buffer], filename, { type: contentType });
104
+ form.append('files', fileObj);
105
+ } else {
106
+ const blob = new Blob([buffer], { type: contentType });
107
+ form.append('files', blob, filename);
108
+ }
109
+ } else if (f.buffer) {
110
+ const filename = f.filename || 'file';
111
+ const contentType = f.contentType || guessContentTypeFromFilename(filename) || 'application/octet-stream';
112
+ if (typeof File !== 'undefined') {
113
+ const fileObj = new File([f.buffer], filename, { type: contentType });
114
+ form.append('files', fileObj);
115
+ } else {
116
+ const blob = new Blob([f.buffer], { type: contentType });
117
+ form.append('files', blob, filename);
118
+ }
119
+ } else if (f.file) {
120
+ form.append('files', f.file, f.file.name || 'file');
121
+ } else {
122
+ throw new Error('each file must contain either path, buffer, or file');
123
+ }
124
+ }
125
+
126
+ const resp = await _doFetch(url, { method: 'POST', body: form }, 60000);
127
+ if (!resp.ok) {
128
+ const body = await safeParseJSON(resp);
129
+ const reason = body?.reason || `${resp.status} ${resp.statusText}`;
130
+ const e = new Error(`Upload failed: ${reason}`);
131
+ e.status = resp.status;
132
+ e.body = body;
133
+ throw e;
134
+ }
135
+ const json = await resp.json();
136
+ if (!json.success) {
137
+ const e = new Error('upload endpoint returned success=false');
138
+ e.body = json;
139
+ throw e;
140
+ }
141
+ return json.uploads || [];
142
+ }
143
+
144
+ async function getUploadStatus(uploadId) {
145
+ const url = new URL(`${config.uploadPath}/${encodeURIComponent(uploadId)}`, config.baseUrl).toString();
146
+ const resp = await _doFetch(url, { method: 'GET' }, 15000);
147
+ if (resp.status === 404) return { missing: true };
148
+ if (!resp.ok) {
149
+ const body = await safeParseJSON(resp);
150
+ const e = new Error('Failed to fetch upload status');
151
+ e.status = resp.status;
152
+ e.body = body;
153
+ throw e;
154
+ }
155
+ return resp.json();
156
+ }
157
+
158
+ async function pollUntilProcessed(uploadId, options = {}) {
159
+ const interval = options.intervalMs ?? config.pollIntervalMs;
160
+ const timeoutMs = options.timeoutMs ?? config.pollTimeoutMs;
161
+ const start = Date.now();
162
+
163
+ while (true) {
164
+ const statusObj = await getUploadStatus(uploadId);
165
+ if (statusObj.status === 'PROCESSED') return statusObj;
166
+ if (statusObj.status === 'DELETED' || statusObj.status === 'ATTACHED') return statusObj;
167
+ if (Date.now() - start > timeoutMs) {
168
+ const e = new Error('polling timeout waiting for processed');
169
+ e.uploadStatus = statusObj;
170
+ throw e;
171
+ }
172
+ await wait(interval);
173
+ }
174
+ }
175
+
176
+ async function pollManyUntilProcessed(uploadIds = [], options = {}) {
177
+ if (!Array.isArray(uploadIds) || uploadIds.length === 0) return {};
178
+ const tasks = uploadIds.map(id => pollUntilProcessed(id, options).then(s => ({ id, s })));
179
+ const results = await Promise.all(tasks);
180
+ const map = {};
181
+ for (const r of results) map[r.id] = r.s;
182
+ return map;
183
+ }
184
+
185
+ /**
186
+ * submitPost
187
+ * replyPermission: if undefined -> defaults to PUBLIC
188
+ * if explicitly provided but empty/falsey -> throws
189
+ */
190
+ async function submitPost({ body = '', medias = [], replyPermission = undefined, quotePostID = null, userIP = null }) {
191
+ // Handle replyPermission defaulting and validation
192
+ let rp;
193
+ if (replyPermission === undefined) {
194
+ rp = 'PUBLIC';
195
+ } else {
196
+ // explicit value provided -> validate it (reject empty)
197
+ rp = validateReplyPermission(replyPermission);
198
+ }
199
+
200
+ const qid = quotePostID == null ? null : validateQuotePostID(quotePostID);
201
+
202
+ const url = new URL(config.postsPath, config.baseUrl).toString();
203
+ const payload = { body, medias, replyPermission: rp, quotePostID: qid };
204
+ if (userIP) payload.userIP = userIP;
205
+ const resp = await _doFetch(url, {
206
+ method: 'POST',
207
+ headers: { 'Content-Type': 'application/json' },
208
+ body: JSON.stringify(payload)
209
+ }, 20000);
210
+
211
+ if (!resp.ok) {
212
+ const parsed = await safeParseJSON(resp);
213
+ const e = new Error('submit post failed');
214
+ e.status = resp.status;
215
+ e.body = parsed;
216
+ throw e;
217
+ }
218
+ return resp.json();
219
+ }
220
+
221
+ /**
222
+ * submitPostWithFiles
223
+ * - If replyPermission undefined -> defaults to PUBLIC
224
+ * - If replyPermission explicitly provided but empty -> throw
225
+ */
226
+ async function submitPostWithFiles({ body = '', files = [], replyPermission = undefined, quotePostID = null, pollOptions = {}, timeoutMs = 120000 }) {
227
+ if (!Array.isArray(files) || files.length === 0) {
228
+ // still require replyPermission defaulting behavior
229
+ return submitPost({ body, medias: [], replyPermission, quotePostID });
230
+ }
231
+
232
+ // validate upfront: default or validate
233
+ if (replyPermission === undefined) {
234
+ // allow defaulting later in submitPost, but keep same normalization here
235
+ } else {
236
+ validateReplyPermission(replyPermission);
237
+ }
238
+ if (quotePostID != null) validateQuotePostID(quotePostID);
239
+
240
+ const uploads = await retryAsync(() => uploadFiles(files), config.maxRetries, 300).catch(err => {
241
+ throw new Error(`Uploading files failed: ${err.message}`);
242
+ });
243
+
244
+ const start = Date.now();
245
+ const finalSrcs = [];
246
+
247
+ for (const uploadId of uploads) {
248
+ const remainingTime = timeoutMs - (Date.now() - start);
249
+ if (remainingTime <= 0) throw new Error('Timeout while waiting for files to be processed');
250
+
251
+ const statusObj = await pollUntilProcessed(uploadId, {
252
+ intervalMs: pollOptions.intervalMs,
253
+ timeoutMs: Math.min(remainingTime, pollOptions.timeoutMs ?? config.pollTimeoutMs)
254
+ });
255
+
256
+ if (statusObj.status !== 'PROCESSED') {
257
+ const e = new Error(`Upload ${uploadId} terminal state: ${statusObj.status}`);
258
+ e.statusObj = statusObj;
259
+ throw e;
260
+ }
261
+ if (!statusObj.src) {
262
+ const e = new Error(`Upload ${uploadId} processed but no src returned`);
263
+ e.statusObj = statusObj;
264
+ throw e;
265
+ }
266
+ finalSrcs.push(statusObj.src);
267
+ }
268
+
269
+ const submitResult = await submitPost({ body, medias: finalSrcs, replyPermission, quotePostID });
270
+ return submitResult;
271
+ }
272
+
273
+ return {
274
+ uploadFiles,
275
+ getUploadStatus,
276
+ pollUntilProcessed,
277
+ pollManyUntilProcessed,
278
+ submitPost,
279
+ submitPostWithFiles,
280
+ _raw: { config }
281
+ };
282
+ }
283
+
284
+ // helpers
285
+ function wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
286
+
287
+ async function safeParseJSON(resp) {
288
+ try { return await resp.json(); } catch (e) { return null; }
289
+ }
290
+
291
+ async function retryAsync(fn, retries = 3, backoffMs = 200) {
292
+ let i = 0;
293
+ while (true) {
294
+ try { return await fn(); } catch (e) {
295
+ i++;
296
+ if (i > retries) throw e;
297
+ await wait(backoffMs * i);
298
+ }
299
+ }
300
+ }
301
+
302
+ function guessContentTypeFromFilename(name) {
303
+ if (!name) return null;
304
+ const ext = name.split('.').pop().toLowerCase();
305
+ if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
306
+ if (ext === 'png') return 'image/png';
307
+ if (ext === 'webp') return 'image/webp';
308
+ if (ext === 'gif') return 'image/gif';
309
+ return null;
310
+ }
311
+
312
+ module.exports = createClient;
package/lib/http.js ADDED
@@ -0,0 +1,19 @@
1
+ // lib/http.js
2
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
3
+ const controller = new AbortController();
4
+ const id = setTimeout(() => controller.abort(), timeoutMs);
5
+ try {
6
+ const resp = await fetch(url, { ...options, signal: controller.signal });
7
+ clearTimeout(id);
8
+ return resp;
9
+ } catch (err) {
10
+ clearTimeout(id);
11
+ if (err.name === 'AbortError') {
12
+ const e = new Error(`Request timed out after ${timeoutMs}ms`);
13
+ e.code = 'ETIMEDOUT';
14
+ throw e;
15
+ }
16
+ throw err;
17
+ }
18
+ }
19
+ module.exports = { fetchWithTimeout };
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@boodibox/api-client",
3
+ "version": "0.1.0",
4
+ "description": "Minimal client for BoodiBox API - submit posts - automate - upload media.",
5
+ "main": "index.js",
6
+ "license": "MIT",
7
+ "keywords": ["boodibox","api","uploads","images","posts","automate","bot"],
8
+ "author": "SC0d3r",
9
+ "scripts": {
10
+ "test": "bun test"
11
+ }
12
+ }
13
+
Binary file
@@ -0,0 +1,36 @@
1
+ // test/e2e.bun.test.js
2
+ import { describe, test, expect } from 'bun:test';
3
+ import createClient from '../index.js';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ const API_KEY = process.env.API_KEY;
8
+ const BASE = process.env.BASE_URL || 'http://localhost:3000';
9
+
10
+ describe('BoodiBox client integration (conditional)', () => {
11
+ if (!API_KEY || process.env.TEST_INTEGRATION !== 'true') {
12
+ test('skipped', () => {
13
+ console.log('Integration tests skipped. Set API_KEY and TEST_INTEGRATION=true to run.');
14
+ expect(true).toBe(true);
15
+ });
16
+ return;
17
+ }
18
+
19
+ const client = createClient({ baseUrl: BASE, apiKey: API_KEY });
20
+
21
+ test('upload -> poll -> submit post (integration)', async () => {
22
+ const tiny = path.join(process.cwd(), 'test', 'assets', 'tiny.jpg');
23
+ if (!fs.existsSync(tiny)) throw new Error('test/assets/tiny.jpg missing');
24
+
25
+ // Submit with default PUBLIC replyPermission
26
+ const result = await client.submitPostWithFiles({
27
+ body: 'bun integration test ' + Date.now(),
28
+ files: [{ path: tiny }],
29
+ pollOptions: { intervalMs: 1000, timeoutMs: 60000 },
30
+ timeoutMs: 90000
31
+ });
32
+
33
+ expect(result).toBeDefined();
34
+ if (result.success !== undefined) expect(result.success).toBeTruthy();
35
+ }, { timeout: 120000 });
36
+ });