@dichovsky/testrail-api-client 1.0.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.
@@ -0,0 +1,601 @@
1
+ import { base64Encode, sleep } from './utils.js';
2
+ import { TestRailApiError, TestRailValidationError } from './errors.js';
3
+ import pkg from '../package.json' with { type: 'json' };
4
+ const USER_AGENT = `${pkg.description}/${pkg.version}`;
5
+ import { BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, MAX_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRIES, DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_CLEANUP_INTERVAL_MS, DEFAULT_MAX_CACHE_SIZE, DEFAULT_RATE_LIMIT_MAX_REQUESTS, DEFAULT_RATE_LIMIT_WINDOW_MS, } from './constants.js';
6
+ // Reject loopback, link-local, and private-range hosts to prevent SSRF.
7
+ // All requests carry a full Authorization header, making the client a credentialed
8
+ // probe for internal services when baseUrl is attacker-controlled.
9
+ // NOTE: This check is purely syntactic (regex on the hostname string). It does NOT
10
+ // resolve DNS, so a public-looking hostname that resolves to a private IP, or a
11
+ // DNS-rebinding attack, can still bypass this protection. For full SSRF prevention
12
+ // use a network-level egress filter or a proxy that validates resolved addresses.
13
+ const PRIVATE_HOST_PATTERNS = [
14
+ /^localhost\.?$/i, // matches "localhost" with or without trailing dot
15
+ /^127\./,
16
+ /^10\./,
17
+ /^172\.(1[6-9]|2\d|3[01])\./,
18
+ /^192\.168\./,
19
+ /^169\.254\./,
20
+ /^::1$/,
21
+ /^fe80:/i, // IPv6 link-local (fe80::/10)
22
+ /^f[cd][0-9a-f]{2}:/i, // IPv6 unique-local (fc00::/7 covers fc** and fd**)
23
+ /^0\./,
24
+ ];
25
+ function validatePublicHost(hostname) {
26
+ // Strip enclosing brackets from IPv6 literals (e.g. "[::1]" → "::1")
27
+ const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
28
+ for (const pattern of PRIVATE_HOST_PATTERNS) {
29
+ if (pattern.test(bare)) {
30
+ throw new TestRailValidationError(`baseUrl resolves to a private/loopback host ("${hostname}"). ` +
31
+ 'Set allowPrivateHosts: true to allow on-premise deployments.');
32
+ }
33
+ }
34
+ }
35
+ const activeClients = new Set();
36
+ let processHandlersRegistered = false;
37
+ // Synchronous-only cleanup — safe to call on process exit
38
+ function cleanupAllClients() {
39
+ for (const client of activeClients) {
40
+ client.destroy();
41
+ }
42
+ }
43
+ function registerProcessHandlers() {
44
+ if (processHandlersRegistered) {
45
+ return;
46
+ }
47
+ if (typeof process !== 'undefined' && typeof process.on === 'function') {
48
+ process.on('exit', cleanupAllClients);
49
+ process.on('SIGINT', () => {
50
+ cleanupAllClients();
51
+ process.exit(130);
52
+ });
53
+ process.on('SIGTERM', () => {
54
+ cleanupAllClients();
55
+ process.exit(143);
56
+ });
57
+ processHandlersRegistered = true;
58
+ }
59
+ }
60
+ /**
61
+ * HTTP pipeline, caching, rate limiting, retry logic, and lifecycle management.
62
+ * Extended by {@link TestRailClient} which adds all API endpoint methods.
63
+ */
64
+ export class TestRailClientCore {
65
+ baseUrl;
66
+ // Declared non-readonly so it can be zeroed in destroy() to reduce
67
+ // the window during which the credential is recoverable from a heap dump.
68
+ auth;
69
+ timeout;
70
+ maxRetries;
71
+ enableCache;
72
+ cacheTtl;
73
+ cacheCleanupInterval;
74
+ maxCacheSize;
75
+ cache = new Map();
76
+ cacheCleanupTimer;
77
+ rateLimiter;
78
+ isDestroyed = false;
79
+ constructor(config) {
80
+ this.validateConfig(config);
81
+ this.baseUrl = config.baseUrl.replace(/\/$/, '');
82
+ this.auth = base64Encode(`${config.email}:${config.apiKey}`);
83
+ this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
84
+ this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
85
+ this.enableCache = config.enableCache ?? true;
86
+ this.cacheTtl = config.cacheTtl ?? DEFAULT_CACHE_TTL_MS;
87
+ this.cacheCleanupInterval = config.cacheCleanupInterval ?? DEFAULT_CACHE_CLEANUP_INTERVAL_MS;
88
+ // maxCacheSize=0 means unbounded and risks memory exhaustion.
89
+ // Warn at construction time so callers are aware of the risk.
90
+ if (config.maxCacheSize === 0 && (config.enableCache ?? true)) {
91
+ // eslint-disable-next-line no-console
92
+ console.warn('Warning: maxCacheSize is set to 0 (unlimited). ' +
93
+ 'This can cause unbounded memory growth. Consider setting a positive limit.');
94
+ }
95
+ this.maxCacheSize = config.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE;
96
+ this.rateLimiter = {
97
+ maxRequests: config.rateLimiter?.maxRequests ?? DEFAULT_RATE_LIMIT_MAX_REQUESTS,
98
+ windowMs: config.rateLimiter?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,
99
+ requests: [],
100
+ };
101
+ // Register this instance for automatic cleanup
102
+ activeClients.add(this);
103
+ registerProcessHandlers();
104
+ // Start periodic cache cleanup if enabled
105
+ if (this.enableCache && this.cacheCleanupInterval > 0) {
106
+ this.startCacheCleanup();
107
+ }
108
+ }
109
+ validateConfig(config) {
110
+ if (!config.baseUrl || typeof config.baseUrl !== 'string') {
111
+ throw new TestRailValidationError('baseUrl is required and must be a string');
112
+ }
113
+ if (!config.email || typeof config.email !== 'string') {
114
+ throw new TestRailValidationError('email is required and must be a string');
115
+ }
116
+ if (!config.apiKey || typeof config.apiKey !== 'string') {
117
+ throw new TestRailValidationError('apiKey is required and must be a string');
118
+ }
119
+ // Validate URL format
120
+ try {
121
+ const url = new URL(config.baseUrl);
122
+ if (!['http:', 'https:'].includes(url.protocol)) {
123
+ throw new TestRailValidationError('baseUrl must use http or https protocol');
124
+ }
125
+ // Block HTTP unless explicitly opted in — Basic auth credentials are base64
126
+ // only; any network observer can decode them from a cleartext HTTP request.
127
+ if (url.protocol === 'http:' && config.allowInsecure !== true) {
128
+ throw new TestRailValidationError('baseUrl must use HTTPS. HTTP sends credentials in cleartext. ' +
129
+ 'Set allowInsecure: true only in isolated development environments.');
130
+ }
131
+ // Block SSRF targets (loopback, link-local, private ranges) unless
132
+ // the caller explicitly opts in for on-premise/private-network deployments.
133
+ if (config.allowPrivateHosts !== true) {
134
+ validatePublicHost(url.hostname);
135
+ }
136
+ }
137
+ catch (err) {
138
+ if (err instanceof TestRailValidationError) {
139
+ throw err;
140
+ }
141
+ throw new TestRailValidationError('baseUrl must be a valid URL');
142
+ }
143
+ // Validate email format
144
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
145
+ if (!emailRegex.test(config.email)) {
146
+ throw new TestRailValidationError('email must be a valid email address');
147
+ }
148
+ // Validate timeout if provided
149
+ if (config.timeout !== undefined) {
150
+ if (typeof config.timeout !== 'number' || config.timeout <= 0 || config.timeout > MAX_TIMEOUT_MS) {
151
+ throw new TestRailValidationError('timeout must be a positive number not exceeding 5 minutes');
152
+ }
153
+ }
154
+ // Validate maxRetries if provided
155
+ if (config.maxRetries !== undefined) {
156
+ if (typeof config.maxRetries !== 'number' || config.maxRetries < 0 || config.maxRetries > 10) {
157
+ throw new TestRailValidationError('maxRetries must be a number between 0 and 10');
158
+ }
159
+ }
160
+ // Validate maxCacheSize if provided
161
+ if (config.maxCacheSize !== undefined) {
162
+ if (typeof config.maxCacheSize !== 'number' ||
163
+ !Number.isInteger(config.maxCacheSize) ||
164
+ config.maxCacheSize < 0) {
165
+ throw new TestRailValidationError('maxCacheSize must be a non-negative integer');
166
+ }
167
+ }
168
+ // Validate rateLimiter config values.
169
+ // Zero or negative maxRequests silently disables or inverts limiting.
170
+ // Zero or negative windowMs makes the window always empty, disabling limiting.
171
+ if (config.rateLimiter !== undefined) {
172
+ if (config.rateLimiter === null || typeof config.rateLimiter !== 'object') {
173
+ throw new TestRailValidationError('rateLimiter must be an object with maxRequests and windowMs');
174
+ }
175
+ if (typeof config.rateLimiter.maxRequests !== 'number' ||
176
+ !Number.isInteger(config.rateLimiter.maxRequests) ||
177
+ config.rateLimiter.maxRequests < 1) {
178
+ throw new TestRailValidationError('rateLimiter.maxRequests must be a positive integer');
179
+ }
180
+ if (typeof config.rateLimiter.windowMs !== 'number' ||
181
+ !Number.isInteger(config.rateLimiter.windowMs) ||
182
+ config.rateLimiter.windowMs < 1) {
183
+ throw new TestRailValidationError('rateLimiter.windowMs must be a positive integer');
184
+ }
185
+ }
186
+ }
187
+ getRetryDelay(retryCount) {
188
+ return Math.min(BASE_RETRY_DELAY_MS * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS);
189
+ }
190
+ /**
191
+ * Parses the Retry-After header value to milliseconds
192
+ *
193
+ * @param response - The HTTP response containing the Retry-After header
194
+ * @returns The delay in milliseconds, or null if header is absent or invalid
195
+ */
196
+ parseRetryAfterMs(response) {
197
+ const retryAfter = response.headers.get('Retry-After');
198
+ if (retryAfter === null || retryAfter === '') {
199
+ return null;
200
+ }
201
+ // Try parsing as seconds (numeric value)
202
+ const seconds = parseInt(retryAfter, 10);
203
+ if (!isNaN(seconds) && seconds > 0) {
204
+ // Cap server-supplied delay to MAX_RETRY_DELAY_MS to prevent a
205
+ // malicious/compromised server from freezing the client indefinitely.
206
+ return Math.min(seconds * 1000, MAX_RETRY_DELAY_MS);
207
+ }
208
+ // Try parsing as HTTP-date format
209
+ const date = new Date(retryAfter);
210
+ if (!isNaN(date.getTime())) {
211
+ const delayMs = date.getTime() - Date.now();
212
+ // Same cap applied to HTTP-date format.
213
+ return delayMs > 0 ? Math.min(delayMs, MAX_RETRY_DELAY_MS) : null;
214
+ }
215
+ return null; // Invalid format
216
+ }
217
+ /** Sliding window rate limiter. @throws {TestRailApiError} when limit exceeded */
218
+ checkRateLimit() {
219
+ const now = Date.now();
220
+ const windowStart = now - this.rateLimiter.windowMs;
221
+ // Clean old requests outside the window
222
+ this.rateLimiter.requests = this.rateLimiter.requests.filter((time) => time > windowStart);
223
+ if (this.rateLimiter.requests.length >= this.rateLimiter.maxRequests) {
224
+ let oldestRequest = now;
225
+ for (const requestTime of this.rateLimiter.requests) {
226
+ if (requestTime < oldestRequest) {
227
+ oldestRequest = requestTime;
228
+ }
229
+ }
230
+ const waitTime = oldestRequest + this.rateLimiter.windowMs - now;
231
+ throw new TestRailApiError(`Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds before making another request.`);
232
+ }
233
+ this.rateLimiter.requests.push(now);
234
+ }
235
+ /**
236
+ * Validates that an ID is a positive integer.
237
+ * @throws {TestRailValidationError} When ID is invalid
238
+ */
239
+ validateId(id, name) {
240
+ if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) {
241
+ throw new TestRailValidationError(`${name} must be a positive integer`);
242
+ }
243
+ }
244
+ /**
245
+ * Validates that a string entry ID is non-empty.
246
+ * @throws {TestRailValidationError} When entryId is not a non-empty string
247
+ */
248
+ validateEntryId(entryId) {
249
+ if (typeof entryId !== 'string' || entryId.trim() === '') {
250
+ throw new TestRailValidationError('entryId must be a non-empty string');
251
+ }
252
+ }
253
+ /**
254
+ * Validates optional pagination parameters.
255
+ * @throws {TestRailValidationError} When limit is not a positive integer or offset is not a non-negative integer
256
+ */
257
+ validatePaginationParams(limit, offset) {
258
+ if (limit !== undefined) {
259
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
260
+ throw new TestRailValidationError('limit must be a positive integer');
261
+ }
262
+ }
263
+ if (offset !== undefined) {
264
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
265
+ throw new TestRailValidationError('offset must be a non-negative integer');
266
+ }
267
+ }
268
+ }
269
+ /**
270
+ * Builds a TestRail endpoint URL with optional query parameters.
271
+ * Appends params using `&key=value` (TestRail URL quirk — uses `&`, not `?`).
272
+ * Keys and values are automatically percent-encoded via `encodeURIComponent`.
273
+ * Do NOT pre-encode values before passing them; doing so will cause double-encoding.
274
+ */
275
+ buildEndpoint(base, params = {}) {
276
+ const parts = [];
277
+ for (const [key, value] of Object.entries(params)) {
278
+ if (value !== undefined) {
279
+ // Encode values to prevent parameter injection via string values
280
+ // containing `&`, `=`, or `#`.
281
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
282
+ }
283
+ }
284
+ return parts.length > 0 ? `${base}&${parts.join('&')}` : base;
285
+ }
286
+ getCachedData(cacheKey) {
287
+ if (!this.enableCache) {
288
+ return undefined;
289
+ }
290
+ const entry = this.cache.get(cacheKey);
291
+ if (entry !== undefined && entry.expiry > Date.now()) {
292
+ // Move to end to mark as recently used (LRU behavior)
293
+ this.cache.delete(cacheKey);
294
+ this.cache.set(cacheKey, entry);
295
+ return entry.data;
296
+ }
297
+ // Clean expired entry
298
+ if (entry !== undefined) {
299
+ this.cache.delete(cacheKey);
300
+ }
301
+ return undefined;
302
+ }
303
+ setCachedData(cacheKey, data) {
304
+ if (!this.enableCache) {
305
+ return;
306
+ }
307
+ // Enforce cache size limit if not zero
308
+ if (this.maxCacheSize > 0 && this.cache.size >= this.maxCacheSize) {
309
+ // Map preserves insertion order; first key is the oldest (LRU eviction)
310
+ // Cache is non-empty here (size >= maxCacheSize > 0), so next().value is always defined
311
+ const oldestKey = this.cache.keys().next().value;
312
+ this.cache.delete(oldestKey);
313
+ }
314
+ this.cache.set(cacheKey, {
315
+ data,
316
+ expiry: Date.now() + this.cacheTtl,
317
+ });
318
+ }
319
+ /**
320
+ * Clears the entire cache.
321
+ */
322
+ clearCache() {
323
+ this.cache.clear();
324
+ }
325
+ startCacheCleanup() {
326
+ this.cacheCleanupTimer = setInterval(() => {
327
+ this.cleanupExpiredCache();
328
+ }, this.cacheCleanupInterval);
329
+ // When cache cleanup is enabled (enableCache is true and cacheCleanupInterval > 0),
330
+ // ensure this timer doesn't prevent process exit in Node.js; the unref check keeps
331
+ // compatibility with non-Node.js environments where unref may not exist.
332
+ this.cacheCleanupTimer.unref?.();
333
+ }
334
+ stopCacheCleanup() {
335
+ if (this.cacheCleanupTimer !== undefined) {
336
+ clearInterval(this.cacheCleanupTimer);
337
+ this.cacheCleanupTimer = undefined;
338
+ }
339
+ }
340
+ cleanupExpiredCache() {
341
+ const now = Date.now();
342
+ // Collect keys of expired entries first to avoid mutating the Map
343
+ // while iterating over its live iterator.
344
+ const keysToDelete = [];
345
+ for (const [key, entry] of this.cache.entries()) {
346
+ if (entry.expiry <= now) {
347
+ keysToDelete.push(key);
348
+ }
349
+ }
350
+ for (const key of keysToDelete) {
351
+ this.cache.delete(key);
352
+ }
353
+ }
354
+ /**
355
+ * Releases all resources held by this client instance.
356
+ * Stops the cache cleanup timer, clears the cache, and removes this instance
357
+ * from the active-clients registry. Safe to call multiple times (idempotent).
358
+ * Also called automatically on `exit`, `SIGINT`, and `SIGTERM`.
359
+ */
360
+ destroy() {
361
+ if (this.isDestroyed) {
362
+ return;
363
+ }
364
+ this.isDestroyed = true;
365
+ this.stopCacheCleanup();
366
+ this.clearCache();
367
+ // Zero the in-memory credential to reduce exposure window.
368
+ this.auth = '';
369
+ // Remove this instance from the active clients set
370
+ activeClients.delete(this);
371
+ }
372
+ /**
373
+ * Makes an HTTP request to the TestRail API with caching, rate limiting, and retry logic.
374
+ *
375
+ * @param method - HTTP method (GET, POST)
376
+ * @param endpoint - API endpoint path (without base URL prefix)
377
+ * @param data - Optional request body
378
+ * @param retryCount - Current retry attempt (internal — do not pass)
379
+ * @param skipCache - Skip cache lookup and storage for this request
380
+ * @throws {TestRailApiError} When the API request fails or network error occurs
381
+ * @throws {Error} When called after `destroy()`
382
+ */
383
+ async request(method, endpoint, data, retryCount = 0, skipCache = false) {
384
+ // Prevent use after destroy
385
+ if (this.isDestroyed) {
386
+ throw new Error('Cannot use TestRailClient after destroy() has been called');
387
+ }
388
+ // Check cache for GET requests
389
+ if (method === 'GET' && !skipCache) {
390
+ const cacheKey = `${method}:${endpoint}`;
391
+ const cachedData = this.getCachedData(cacheKey);
392
+ if (cachedData !== undefined) {
393
+ return cachedData;
394
+ }
395
+ }
396
+ // Apply rate limiting
397
+ this.checkRateLimit();
398
+ const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
399
+ const headers = {
400
+ Authorization: `Basic ${this.auth}`,
401
+ 'Content-Type': 'application/json',
402
+ 'User-Agent': USER_AGENT,
403
+ };
404
+ const controller = new AbortController();
405
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
406
+ const options = {
407
+ method,
408
+ headers,
409
+ signal: controller.signal,
410
+ };
411
+ if (data !== undefined) {
412
+ options.body = JSON.stringify(data);
413
+ }
414
+ try {
415
+ const response = await fetch(url, options);
416
+ clearTimeout(timeoutId);
417
+ if (!response.ok) {
418
+ const errorText = await response.text().catch(() => 'Unknown error');
419
+ // Retry strategy for 5xx (Server Errors) and 429 (Too Many Requests).
420
+ // For 429, respect the Retry-After header if present; otherwise use exponential backoff.
421
+ if ((response.status >= 500 || response.status === 429) && retryCount < this.maxRetries) {
422
+ const retryAfterMs = response.status === 429 ? this.parseRetryAfterMs(response) : null;
423
+ const delay = retryAfterMs ?? this.getRetryDelay(retryCount);
424
+ await sleep(delay);
425
+ return this.request(method, endpoint, data, retryCount + 1, skipCache);
426
+ }
427
+ // The raw server body may contain stack traces, internal paths,
428
+ // or secret values. Keep it in the structured `response` field for
429
+ // programmatic inspection but do not embed it in the message string,
430
+ // which callers commonly pass to loggers.
431
+ throw new TestRailApiError(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
432
+ }
433
+ // Invalidate cache after mutating requests to avoid stale GET results.
434
+ // Done before the empty-body check so empty responses (e.g. delete endpoints)
435
+ // also clear the cache.
436
+ if (method !== 'GET') {
437
+ this.clearCache();
438
+ }
439
+ const responseText = await response.text();
440
+ if (!responseText) {
441
+ return {};
442
+ }
443
+ try {
444
+ const result = JSON.parse(responseText);
445
+ // Cache successful GET responses
446
+ if (method === 'GET' && !skipCache) {
447
+ const cacheKey = `${method}:${endpoint}`;
448
+ this.setCachedData(cacheKey, result);
449
+ }
450
+ return result;
451
+ }
452
+ catch {
453
+ throw new TestRailApiError('Invalid JSON response from TestRail API');
454
+ }
455
+ }
456
+ catch (error) {
457
+ clearTimeout(timeoutId);
458
+ if (error instanceof TestRailApiError) {
459
+ throw error;
460
+ }
461
+ const isAbortError = error.name === 'AbortError';
462
+ // Don't retry timeout errors to avoid excessive wait times
463
+ if (isAbortError) {
464
+ throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
465
+ }
466
+ // Retry on network errors up to the maximum number of retries
467
+ if (retryCount < this.maxRetries) {
468
+ await sleep(this.getRetryDelay(retryCount));
469
+ return this.request(method, endpoint, data, retryCount + 1, skipCache);
470
+ }
471
+ throw new TestRailApiError(`Network error: ${error.message}`, undefined, undefined, error.message);
472
+ }
473
+ }
474
+ /**
475
+ * Makes a multipart/form-data POST request to the TestRail API.
476
+ * Used exclusively for file attachment uploads. Applies rate limiting
477
+ * and throws on failure, but does NOT retry (uploads are not idempotent).
478
+ *
479
+ * @param endpoint - API endpoint path (without base URL prefix)
480
+ * @param file - File content as Blob, Uint8Array, or File
481
+ * @param filename - Filename to send in the multipart disposition
482
+ * @throws {TestRailApiError} When the API request fails or network error occurs
483
+ * @throws {Error} When called after `destroy()`
484
+ */
485
+ async requestMultipart(endpoint, file, filename) {
486
+ if (this.isDestroyed) {
487
+ throw new Error('Cannot use TestRailClient after destroy() has been called');
488
+ }
489
+ this.checkRateLimit();
490
+ const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
491
+ const formData = new globalThis.FormData();
492
+ let blob;
493
+ if (file instanceof globalThis.Blob) {
494
+ blob = file;
495
+ }
496
+ else {
497
+ // Copy binary-like input into a plain Uint8Array to satisfy BlobPart type constraints
498
+ blob = new globalThis.Blob([new Uint8Array(file)]);
499
+ }
500
+ formData.append('attachment', blob, filename);
501
+ const controller = new AbortController();
502
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
503
+ try {
504
+ const response = await fetch(url, {
505
+ method: 'POST',
506
+ headers: {
507
+ Authorization: `Basic ${this.auth}`,
508
+ 'User-Agent': USER_AGENT,
509
+ // Do NOT set Content-Type — fetch sets it automatically with the boundary
510
+ },
511
+ body: formData,
512
+ signal: controller.signal,
513
+ });
514
+ clearTimeout(timeoutId);
515
+ if (!response.ok) {
516
+ const errorText = await response.text().catch(() => 'Unknown error');
517
+ throw new TestRailApiError(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
518
+ }
519
+ // Invalidate cache after upload
520
+ this.clearCache();
521
+ const responseText = await response.text();
522
+ if (!responseText) {
523
+ return {};
524
+ }
525
+ try {
526
+ return JSON.parse(responseText);
527
+ }
528
+ catch {
529
+ throw new TestRailApiError('Invalid JSON response from TestRail API');
530
+ }
531
+ }
532
+ catch (error) {
533
+ clearTimeout(timeoutId);
534
+ if (error instanceof TestRailApiError) {
535
+ throw error;
536
+ }
537
+ const isAbortError = error.name === 'AbortError';
538
+ if (isAbortError) {
539
+ throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
540
+ }
541
+ throw new TestRailApiError(`Network error: ${error.message}`, undefined, undefined, error.message);
542
+ }
543
+ }
544
+ /**
545
+ * Makes a GET request to the TestRail API and returns the raw binary response.
546
+ * Used for downloading attachment contents.
547
+ *
548
+ * @param endpoint - API endpoint path (without base URL prefix)
549
+ * @throws {TestRailApiError} When the API request fails or network error occurs
550
+ * @throws {Error} When called after `destroy()`
551
+ */
552
+ async requestBinary(endpoint, retryCount = 0) {
553
+ if (this.isDestroyed) {
554
+ throw new Error('Cannot use TestRailClient after destroy() has been called');
555
+ }
556
+ this.checkRateLimit();
557
+ const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
558
+ const controller = new AbortController();
559
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
560
+ try {
561
+ const response = await fetch(url, {
562
+ method: 'GET',
563
+ headers: {
564
+ Authorization: `Basic ${this.auth}`,
565
+ 'User-Agent': USER_AGENT,
566
+ },
567
+ signal: controller.signal,
568
+ });
569
+ clearTimeout(timeoutId);
570
+ if (!response.ok) {
571
+ const errorText = await response.text().catch(() => 'Unknown error');
572
+ // Retry strategy for 5xx (Server Errors) and 429 (Too Many Requests).
573
+ // For 429, respect Retry-After header if present; otherwise use exponential backoff.
574
+ if ((response.status >= 500 || response.status === 429) && retryCount < this.maxRetries) {
575
+ const retryAfterMs = response.status === 429 ? this.parseRetryAfterMs(response) : null;
576
+ const delay = retryAfterMs ?? this.getRetryDelay(retryCount);
577
+ await sleep(delay);
578
+ return this.requestBinary(endpoint, retryCount + 1);
579
+ }
580
+ throw new TestRailApiError(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
581
+ }
582
+ return response.arrayBuffer();
583
+ }
584
+ catch (error) {
585
+ clearTimeout(timeoutId);
586
+ if (error instanceof TestRailApiError) {
587
+ throw error;
588
+ }
589
+ const isAbortError = error.name === 'AbortError';
590
+ if (isAbortError) {
591
+ throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
592
+ }
593
+ if (retryCount < this.maxRetries) {
594
+ await sleep(this.getRetryDelay(retryCount));
595
+ return this.requestBinary(endpoint, retryCount + 1);
596
+ }
597
+ throw new TestRailApiError(`Network error: ${error.message}`, undefined, undefined, error.message);
598
+ }
599
+ }
600
+ }
601
+ //# sourceMappingURL=client-core.js.map