@rbaileysr/zephyr-managed-api 1.2.0 → 1.2.8

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,2109 @@
1
+ /*!
2
+ * Copyright Adaptavist 2025 (c) All rights reserved
3
+ */
4
+ import { PrivateBase } from './PrivateBase';
5
+ import { BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ServerError, UnexpectedError } from '../../utils';
6
+ export class PrivateAttachments extends PrivateBase {
7
+ constructor(apiConnection) {
8
+ super(apiConnection);
9
+ }
10
+ /**
11
+ * Get attachments for a test case using private API
12
+ *
13
+ * Retrieves all attachment details for a test case, including signed S3 URLs
14
+ * for downloading the attachments.
15
+ *
16
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
17
+ * The endpoint may change or be removed at any time without notice.
18
+ *
19
+ * @param credentials - Private API credentials
20
+ * @param request - Get attachments request
21
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
22
+ * @param request.projectId - Jira project ID (numeric, not the project key)
23
+ * @returns Test case attachments response with array of attachment details
24
+ * @throws {BadRequestError} If the request is invalid
25
+ * @throws {UnauthorizedError} If authentication fails
26
+ * @throws {ForbiddenError} If the user doesn't have permission
27
+ * @throws {NotFoundError} If the test case is not found
28
+ * @throws {ServerError} If the server returns an error
29
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available
30
+ */
31
+ async getTestCaseAttachments(credentials, request) {
32
+ // Get test case ID from key if we have API connection
33
+ let testCaseId;
34
+ if (this.testCaseGroup) {
35
+ try {
36
+ const testCase = await this.testCaseGroup.getTestCase({ testCaseKey: request.testCaseKey });
37
+ if (!testCase) {
38
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
39
+ }
40
+ testCaseId = testCase.id;
41
+ }
42
+ catch (error) {
43
+ if (error instanceof NotFoundError) {
44
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
45
+ }
46
+ throw new UnexpectedError(`Failed to look up test case ID from key '${request.testCaseKey}'. Ensure Zephyr Connector is configured.`, error);
47
+ }
48
+ }
49
+ else {
50
+ throw new UnexpectedError('Cannot look up test case ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
51
+ }
52
+ // Get Context JWT
53
+ const contextJwt = await this.getContextJwt(credentials);
54
+ const url = `${this.privateApiBaseUrl}/testcase/${testCaseId}?fields=attachments`;
55
+ const headers = {
56
+ 'accept': 'application/json',
57
+ 'authorization': `JWT ${contextJwt}`,
58
+ 'jira-project-id': String(request.projectId),
59
+ };
60
+ try {
61
+ const response = await fetch(url, {
62
+ method: 'GET',
63
+ headers,
64
+ });
65
+ if (!response.ok) {
66
+ if (response.status === 400) {
67
+ throw new BadRequestError('Invalid request parameters for getting attachments.');
68
+ }
69
+ if (response.status === 401) {
70
+ throw new UnauthorizedError('Failed to authenticate. Please check your credentials.');
71
+ }
72
+ if (response.status === 403) {
73
+ throw new ForbiddenError('Insufficient permissions to get attachments.');
74
+ }
75
+ if (response.status === 404) {
76
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
77
+ }
78
+ throw new ServerError(`Failed to get test case attachments. Status: ${response.status}`, response.status, response.statusText);
79
+ }
80
+ return await response.json();
81
+ }
82
+ catch (error) {
83
+ if (error instanceof BadRequestError ||
84
+ error instanceof UnauthorizedError ||
85
+ error instanceof ForbiddenError ||
86
+ error instanceof NotFoundError ||
87
+ error instanceof ServerError ||
88
+ error instanceof UnexpectedError) {
89
+ throw error;
90
+ }
91
+ throw new UnexpectedError('Unexpected error while getting test case attachments', error);
92
+ }
93
+ }
94
+ /**
95
+ * Download an attachment from a test case using private API
96
+ *
97
+ * Downloads an attachment file into memory. This method:
98
+ * 1. Gets fresh attachment details (including a fresh signed S3 URL)
99
+ * 2. Downloads the file from the signed URL
100
+ * 3. Returns the file as a Blob
101
+ *
102
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
103
+ * The endpoints may change or be removed at any time without notice.
104
+ *
105
+ * @param credentials - Private API credentials
106
+ * @param request - Download attachment request
107
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
108
+ * @param request.projectId - Jira project ID (numeric, not the project key)
109
+ * @param request.attachmentId - Attachment ID (UUID string, e.g., 'c3f14125-638f-47f9-9211-12a9777c09e7')
110
+ * @returns Blob containing the attachment file data
111
+ * @throws {BadRequestError} If the request is invalid
112
+ * @throws {UnauthorizedError} If authentication fails
113
+ * @throws {ForbiddenError} If the user doesn't have permission
114
+ * @throws {NotFoundError} If the test case or attachment is not found
115
+ * @throws {ServerError} If the server returns an error
116
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available, or if download fails
117
+ */
118
+ async downloadAttachment(credentials, request) {
119
+ // Step 1: Get fresh attachment details (including fresh signed URL)
120
+ const attachmentsResponse = await this.getTestCaseAttachments(credentials, {
121
+ testCaseKey: request.testCaseKey,
122
+ projectId: request.projectId,
123
+ });
124
+ // Step 2: Find the requested attachment
125
+ const attachment = attachmentsResponse.attachments.find((att) => att.id === request.attachmentId);
126
+ if (!attachment) {
127
+ throw new NotFoundError(`Attachment with ID '${request.attachmentId}' not found for test case '${request.testCaseKey}'.`);
128
+ }
129
+ // Step 3: Download the file from the signed S3 URL
130
+ try {
131
+ const downloadResponse = await fetch(attachment.url, {
132
+ method: 'GET',
133
+ });
134
+ if (!downloadResponse.ok) {
135
+ if (downloadResponse.status === 403) {
136
+ throw new ForbiddenError('Failed to download attachment. The signed URL may have expired. Please try again.');
137
+ }
138
+ if (downloadResponse.status === 404) {
139
+ throw new NotFoundError('Attachment file not found on S3.');
140
+ }
141
+ throw new ServerError(`Failed to download attachment. Status: ${downloadResponse.status}`, downloadResponse.status, downloadResponse.statusText);
142
+ }
143
+ // Return as Blob
144
+ return await downloadResponse.blob();
145
+ }
146
+ catch (error) {
147
+ if (error instanceof ForbiddenError ||
148
+ error instanceof NotFoundError ||
149
+ error instanceof ServerError) {
150
+ throw error;
151
+ }
152
+ throw new UnexpectedError('Unexpected error while downloading attachment', error);
153
+ }
154
+ }
155
+ /**
156
+ * Create an attachment for a test case using private API
157
+ *
158
+ * Uploads a file attachment to a test case. This involves:
159
+ * 1. Getting upload details (S3 credentials)
160
+ * 2. Uploading the file to S3
161
+ * 3. Saving attachment metadata
162
+ *
163
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
164
+ * The endpoints may change or be removed at any time without notice.
165
+ *
166
+ * @param credentials - Private API credentials
167
+ * @param request - Attachment creation request
168
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
169
+ * @param request.projectId - Jira project ID (numeric, not the project key)
170
+ * @param request.file - File to upload (Blob or ArrayBuffer)
171
+ * @param request.fileName - Name of the file
172
+ * @param request.userAccountId - Atlassian account ID (e.g., '5d6fdc98dc6e480dbc021aae')
173
+ * @returns Attachment metadata response
174
+ * @throws {BadRequestError} If the request is invalid
175
+ * @throws {UnauthorizedError} If authentication fails
176
+ * @throws {ForbiddenError} If the user doesn't have permission
177
+ * @throws {NotFoundError} If the test case is not found
178
+ * @throws {ServerError} If the server returns an error
179
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available
180
+ */
181
+ async createAttachment(credentials, request) {
182
+ // Get test case ID from key if we have API connection
183
+ let testCaseId;
184
+ if (this.testCaseGroup) {
185
+ try {
186
+ const testCase = await this.testCaseGroup.getTestCase({ testCaseKey: request.testCaseKey });
187
+ if (!testCase) {
188
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
189
+ }
190
+ testCaseId = testCase.id;
191
+ }
192
+ catch (error) {
193
+ if (error instanceof NotFoundError) {
194
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
195
+ }
196
+ throw new UnexpectedError(`Failed to look up test case ID from key '${request.testCaseKey}'. Ensure Zephyr Connector is configured.`, error);
197
+ }
198
+ }
199
+ else {
200
+ throw new UnexpectedError('Cannot look up test case ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
201
+ }
202
+ // Get Context JWT
203
+ const contextJwt = await this.getContextJwt(credentials);
204
+ // Step 1: Get upload details
205
+ const uploadDetails = await this.getUploadDetails(contextJwt);
206
+ // Step 2: Upload to S3
207
+ const s3Info = await this.uploadToS3(uploadDetails, request.projectId, testCaseId, request.userAccountId, request.file, request.fileName);
208
+ // Step 3: Save metadata
209
+ return await this.saveAttachmentMetadata(contextJwt, request.projectId, testCaseId, request.userAccountId, s3Info.s3Key, s3Info.fileName, s3Info.mimeType, s3Info.fileSize);
210
+ }
211
+ /**
212
+ * Get upload details for attachment upload
213
+ *
214
+ * Retrieves S3 upload credentials and configuration needed to upload an attachment.
215
+ *
216
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
217
+ * The endpoint may change or be removed at any time without notice.
218
+ *
219
+ * @param contextJwt - Jira Context JWT token
220
+ * @returns Upload details including S3 bucket URL, credentials, and policy
221
+ * @throws {UnauthorizedError} If authentication fails
222
+ * @throws {ServerError} If the server returns an error
223
+ */
224
+ async getUploadDetails(contextJwt) {
225
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/uploaddetails/attachment';
226
+ const headers = {
227
+ 'Authorization': `JWT ${contextJwt}`,
228
+ 'Accept': 'application/json',
229
+ };
230
+ try {
231
+ const response = await fetch(url, {
232
+ method: 'GET',
233
+ headers,
234
+ });
235
+ if (!response.ok) {
236
+ if (response.status === 401) {
237
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
238
+ }
239
+ throw new ServerError(`Failed to get upload details. Status: ${response.status}`, response.status, response.statusText);
240
+ }
241
+ return await response.json();
242
+ }
243
+ catch (error) {
244
+ if (error instanceof UnauthorizedError || error instanceof ServerError) {
245
+ throw error;
246
+ }
247
+ throw new UnexpectedError('Unexpected error while getting upload details', error);
248
+ }
249
+ }
250
+ /**
251
+ * Upload file to S3
252
+ *
253
+ * Uploads a file to S3 using the credentials from upload details.
254
+ *
255
+ * @param upload - Upload details from getUploadDetails
256
+ * @param projectId - Jira project ID
257
+ * @param testCaseId - Numeric test case ID
258
+ * @param userAccountId - Atlassian account ID
259
+ * @param file - File to upload (Blob or ArrayBuffer)
260
+ * @param fileName - Name of the file
261
+ * @returns S3 upload information
262
+ * @throws {UnexpectedError} If upload fails
263
+ */
264
+ async uploadToS3(upload, projectId, testCaseId, userAccountId, file, fileName) {
265
+ const bucketUrl = upload.bucketUrl;
266
+ const keyPrefix = upload.keyPrefix;
267
+ const credential = upload.credential;
268
+ const date = upload.date;
269
+ const policy = upload.policy;
270
+ const signature = upload.signature;
271
+ // Generate UUID for file
272
+ const fileUuid = this.generateUUID();
273
+ const s3Key = `${keyPrefix}/project/${projectId}/testcase/${testCaseId}/${fileUuid}`;
274
+ // Determine MIME type
275
+ const mimeType = this.getMimeType(fileName);
276
+ // Get file size
277
+ const fileSize = file instanceof Blob ? file.size : file.byteLength;
278
+ // Create form data
279
+ const formData = new FormData();
280
+ formData.append('key', s3Key);
281
+ formData.append('acl', 'private');
282
+ formData.append('Policy', policy);
283
+ formData.append('X-Amz-Credential', credential);
284
+ formData.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
285
+ formData.append('X-Amz-Date', date);
286
+ formData.append('X-Amz-Signature', signature);
287
+ formData.append('X-Amz-Meta-user-account-id', userAccountId);
288
+ formData.append('X-Amz-Meta-name', fileName);
289
+ formData.append('Content-Type', mimeType);
290
+ // Add file
291
+ if (file instanceof Blob) {
292
+ formData.append('file', file, fileName);
293
+ }
294
+ else {
295
+ formData.append('file', new Blob([file], { type: mimeType }), fileName);
296
+ }
297
+ try {
298
+ const response = await fetch(bucketUrl, {
299
+ method: 'POST',
300
+ body: formData,
301
+ });
302
+ if (!response.ok && response.status !== 200 && response.status !== 201 && response.status !== 204) {
303
+ const errorText = await response.text().catch(() => 'Upload failed');
304
+ throw new UnexpectedError(`Failed to upload file to S3: ${errorText}`);
305
+ }
306
+ return {
307
+ s3Key,
308
+ fileName,
309
+ mimeType,
310
+ fileSize,
311
+ };
312
+ }
313
+ catch (error) {
314
+ if (error instanceof UnexpectedError) {
315
+ throw error;
316
+ }
317
+ throw new UnexpectedError('Unexpected error while uploading file to S3', error);
318
+ }
319
+ }
320
+ /**
321
+ * Save attachment metadata
322
+ *
323
+ * Saves metadata for an uploaded attachment.
324
+ *
325
+ * @param contextJwt - Jira Context JWT token
326
+ * @param projectId - Jira project ID
327
+ * @param testCaseId - Numeric test case ID
328
+ * @param userAccountId - Atlassian account ID
329
+ * @param s3Key - S3 key from upload
330
+ * @param fileName - File name
331
+ * @param mimeType - MIME type
332
+ * @param fileSize - File size in bytes
333
+ * @returns Attachment metadata response
334
+ * @throws {BadRequestError} If the request is invalid
335
+ * @throws {UnauthorizedError} If authentication fails
336
+ * @throws {ServerError} If the server returns an error
337
+ */
338
+ async saveAttachmentMetadata(contextJwt, projectId, testCaseId, userAccountId, s3Key, fileName, mimeType, fileSize) {
339
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/attachment/metadata';
340
+ const headers = {
341
+ 'Authorization': `JWT ${contextJwt}`,
342
+ 'jira-project-id': String(projectId),
343
+ 'Content-Type': 'application/json',
344
+ 'Accept': 'application/json',
345
+ };
346
+ const createdOn = new Date().toISOString();
347
+ const payload = {
348
+ createdOn,
349
+ mimeType,
350
+ name: fileName,
351
+ s3Key,
352
+ size: fileSize,
353
+ testCaseId,
354
+ userAccountId,
355
+ };
356
+ try {
357
+ const response = await fetch(url, {
358
+ method: 'POST',
359
+ headers,
360
+ body: JSON.stringify(payload),
361
+ });
362
+ if (!response.ok) {
363
+ if (response.status === 400) {
364
+ const errorText = await response.text().catch(() => 'Bad Request');
365
+ throw new BadRequestError(`Failed to save attachment metadata: ${errorText}`, response.statusText);
366
+ }
367
+ if (response.status === 401) {
368
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
369
+ }
370
+ throw new ServerError(`Failed to save attachment metadata. Status: ${response.status}`, response.status, response.statusText);
371
+ }
372
+ return await response.json();
373
+ }
374
+ catch (error) {
375
+ if (error instanceof BadRequestError ||
376
+ error instanceof UnauthorizedError ||
377
+ error instanceof ServerError) {
378
+ throw error;
379
+ }
380
+ throw new UnexpectedError('Unexpected error while saving attachment metadata', error);
381
+ }
382
+ }
383
+ /**
384
+ * Generate a UUID v4 (compatible with ScriptRunner Connect runtime)
385
+ *
386
+ * Uses crypto.getRandomValues() which is available in web standards
387
+ */
388
+ generateUUID() {
389
+ // Use crypto.getRandomValues() which is available in ScriptRunner Connect
390
+ const bytes = new Uint8Array(16);
391
+ crypto.getRandomValues(bytes);
392
+ // Set version (4) and variant bits
393
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
394
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
395
+ // Convert to UUID string format
396
+ const hex = [];
397
+ for (let i = 0; i < bytes.length; i++) {
398
+ hex.push(bytes[i].toString(16).padStart(2, '0'));
399
+ }
400
+ return [
401
+ hex.slice(0, 4).join(''),
402
+ hex.slice(4, 6).join(''),
403
+ hex.slice(6, 8).join(''),
404
+ hex.slice(8, 10).join(''),
405
+ hex.slice(10, 16).join(''),
406
+ ].join('-');
407
+ }
408
+ /**
409
+ * Get MIME type from file name
410
+ */
411
+ getMimeType(fileName) {
412
+ // Simple MIME type detection based on extension
413
+ const extension = fileName.split('.').pop()?.toLowerCase();
414
+ const mimeTypes = {
415
+ png: 'image/png',
416
+ jpg: 'image/jpeg',
417
+ jpeg: 'image/jpeg',
418
+ gif: 'image/gif',
419
+ pdf: 'application/pdf',
420
+ txt: 'text/plain',
421
+ csv: 'text/csv',
422
+ json: 'application/json',
423
+ xml: 'application/xml',
424
+ zip: 'application/zip',
425
+ };
426
+ return mimeTypes[extension || ''] || 'application/octet-stream';
427
+ }
428
+ /**
429
+ * Get attachments for a test cycle using private API
430
+ *
431
+ * Retrieves all attachment details for a test cycle, including signed S3 URLs
432
+ * for downloading the attachments.
433
+ *
434
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
435
+ * The endpoint may change or be removed at any time without notice.
436
+ *
437
+ * @param credentials - Private API credentials
438
+ * @param request - Get attachments request
439
+ * @param request.testCycleKey - Test cycle key (e.g., 'PROJ-R1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
440
+ * @param request.projectId - Jira project ID (numeric, not the project key)
441
+ * @returns Test cycle attachments response with array of attachment details
442
+ * @throws {BadRequestError} If the request is invalid
443
+ * @throws {UnauthorizedError} If authentication fails
444
+ * @throws {ForbiddenError} If the user doesn't have permission
445
+ * @throws {NotFoundError} If the test cycle is not found
446
+ * @throws {ServerError} If the server returns an error
447
+ * @throws {UnexpectedError} If test cycle ID cannot be looked up from key and Zephyr Connector is not available
448
+ */
449
+ async getTestCycleAttachments(credentials, request) {
450
+ // Get test cycle ID from key if we have API connection
451
+ let testCycleId;
452
+ if (this.testCycleGroup) {
453
+ try {
454
+ const testCycle = await this.testCycleGroup.getTestCycle({ testCycleIdOrKey: request.testCycleKey });
455
+ if (!testCycle) {
456
+ throw new NotFoundError(`Test cycle with key '${request.testCycleKey}' not found.`);
457
+ }
458
+ testCycleId = testCycle.id;
459
+ }
460
+ catch (error) {
461
+ if (error instanceof NotFoundError) {
462
+ throw new NotFoundError(`Test cycle with key '${request.testCycleKey}' not found.`);
463
+ }
464
+ throw new UnexpectedError(`Failed to look up test cycle ID from key '${request.testCycleKey}'. Ensure Zephyr Connector is configured.`, error);
465
+ }
466
+ }
467
+ else {
468
+ throw new UnexpectedError('Cannot look up test cycle ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
469
+ }
470
+ // Get Context JWT
471
+ const contextJwt = await this.getContextJwt(credentials);
472
+ const url = `${this.privateApiBaseUrl}/testrun/${testCycleId}?fields=attachments`;
473
+ const headers = {
474
+ 'accept': 'application/json',
475
+ 'authorization': `JWT ${contextJwt}`,
476
+ 'jira-project-id': String(request.projectId),
477
+ };
478
+ try {
479
+ const response = await fetch(url, {
480
+ method: 'GET',
481
+ headers,
482
+ });
483
+ if (!response.ok) {
484
+ if (response.status === 400) {
485
+ throw new BadRequestError('Invalid request parameters for getting attachments.');
486
+ }
487
+ if (response.status === 401) {
488
+ throw new UnauthorizedError('Failed to authenticate. Please check your credentials.');
489
+ }
490
+ if (response.status === 403) {
491
+ throw new ForbiddenError('Insufficient permissions to get attachments.');
492
+ }
493
+ if (response.status === 404) {
494
+ throw new NotFoundError(`Test cycle with key '${request.testCycleKey}' not found.`);
495
+ }
496
+ throw new ServerError(`Failed to get test cycle attachments. Status: ${response.status}`, response.status, response.statusText);
497
+ }
498
+ return await response.json();
499
+ }
500
+ catch (error) {
501
+ if (error instanceof BadRequestError ||
502
+ error instanceof UnauthorizedError ||
503
+ error instanceof ForbiddenError ||
504
+ error instanceof NotFoundError ||
505
+ error instanceof ServerError ||
506
+ error instanceof UnexpectedError) {
507
+ throw error;
508
+ }
509
+ throw new UnexpectedError('Unexpected error while getting test cycle attachments', error);
510
+ }
511
+ }
512
+ /**
513
+ * Download an attachment from a test cycle using private API
514
+ *
515
+ * Downloads an attachment file into memory. This method:
516
+ * 1. Gets fresh attachment details (including a fresh signed S3 URL)
517
+ * 2. Downloads the file from the signed URL
518
+ * 3. Returns the file as a Blob
519
+ *
520
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
521
+ * The endpoints may change or be removed at any time without notice.
522
+ *
523
+ * @param credentials - Private API credentials
524
+ * @param request - Download attachment request
525
+ * @param request.testCycleKey - Test cycle key (e.g., 'PROJ-R1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
526
+ * @param request.projectId - Jira project ID (numeric, not the project key)
527
+ * @param request.attachmentId - Attachment ID (UUID string, e.g., '13fbe8bc-a87b-4db1-bf01-8b4451db79fb')
528
+ * @returns Blob containing the attachment file data
529
+ * @throws {BadRequestError} If the request is invalid
530
+ * @throws {UnauthorizedError} If authentication fails
531
+ * @throws {ForbiddenError} If the user doesn't have permission
532
+ * @throws {NotFoundError} If the test cycle or attachment is not found
533
+ * @throws {ServerError} If the server returns an error
534
+ * @throws {UnexpectedError} If test cycle ID cannot be looked up from key and Zephyr Connector is not available, or if download fails
535
+ */
536
+ async downloadTestCycleAttachment(credentials, request) {
537
+ // Step 1: Get fresh attachment details (including fresh signed URL)
538
+ const attachmentsResponse = await this.getTestCycleAttachments(credentials, {
539
+ testCycleKey: request.testCycleKey,
540
+ projectId: request.projectId,
541
+ });
542
+ // Step 2: Find the requested attachment
543
+ const attachment = attachmentsResponse.attachments.find((att) => att.id === request.attachmentId);
544
+ if (!attachment) {
545
+ throw new NotFoundError(`Attachment with ID '${request.attachmentId}' not found for test cycle '${request.testCycleKey}'.`);
546
+ }
547
+ // Step 3: Download the file from the signed S3 URL
548
+ try {
549
+ const downloadResponse = await fetch(attachment.url, {
550
+ method: 'GET',
551
+ });
552
+ if (!downloadResponse.ok) {
553
+ if (downloadResponse.status === 403) {
554
+ throw new ForbiddenError('Failed to download attachment. The signed URL may have expired. Please try again.');
555
+ }
556
+ if (downloadResponse.status === 404) {
557
+ throw new NotFoundError('Attachment file not found on S3.');
558
+ }
559
+ throw new ServerError(`Failed to download attachment. Status: ${downloadResponse.status}`, downloadResponse.status, downloadResponse.statusText);
560
+ }
561
+ // Return as Blob
562
+ return await downloadResponse.blob();
563
+ }
564
+ catch (error) {
565
+ if (error instanceof ForbiddenError ||
566
+ error instanceof NotFoundError ||
567
+ error instanceof ServerError) {
568
+ throw error;
569
+ }
570
+ throw new UnexpectedError('Unexpected error while downloading attachment', error);
571
+ }
572
+ }
573
+ /**
574
+ * Create an attachment for a test cycle using private API
575
+ *
576
+ * Uploads a file attachment to a test cycle. This involves:
577
+ * 1. Getting upload details (S3 credentials)
578
+ * 2. Uploading the file to S3
579
+ * 3. Saving attachment metadata
580
+ *
581
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
582
+ * The endpoints may change or be removed at any time without notice.
583
+ *
584
+ * @param credentials - Private API credentials
585
+ * @param request - Attachment creation request
586
+ * @param request.testCycleKey - Test cycle key (e.g., 'PROJ-R1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
587
+ * @param request.projectId - Jira project ID (numeric, not the project key)
588
+ * @param request.file - File to upload (Blob or ArrayBuffer)
589
+ * @param request.fileName - Name of the file
590
+ * @param request.userAccountId - Atlassian account ID (e.g., '5d6fdc98dc6e480dbc021aae')
591
+ * @returns Attachment metadata response
592
+ * @throws {BadRequestError} If the request is invalid
593
+ * @throws {UnauthorizedError} If authentication fails
594
+ * @throws {ForbiddenError} If the user doesn't have permission
595
+ * @throws {NotFoundError} If the test cycle is not found
596
+ * @throws {ServerError} If the server returns an error
597
+ * @throws {UnexpectedError} If test cycle ID cannot be looked up from key and Zephyr Connector is not available
598
+ */
599
+ async createTestCycleAttachment(credentials, request) {
600
+ // Get test cycle ID from key if we have API connection
601
+ let testCycleId;
602
+ if (this.testCycleGroup) {
603
+ try {
604
+ const testCycle = await this.testCycleGroup.getTestCycle({ testCycleIdOrKey: request.testCycleKey });
605
+ if (!testCycle) {
606
+ throw new NotFoundError(`Test cycle with key '${request.testCycleKey}' not found.`);
607
+ }
608
+ testCycleId = testCycle.id;
609
+ }
610
+ catch (error) {
611
+ if (error instanceof NotFoundError) {
612
+ throw new NotFoundError(`Test cycle with key '${request.testCycleKey}' not found.`);
613
+ }
614
+ throw new UnexpectedError(`Failed to look up test cycle ID from key '${request.testCycleKey}'. Ensure Zephyr Connector is configured.`, error);
615
+ }
616
+ }
617
+ else {
618
+ throw new UnexpectedError('Cannot look up test cycle ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
619
+ }
620
+ // Get Context JWT
621
+ const contextJwt = await this.getContextJwt(credentials);
622
+ // Step 1: Get upload details
623
+ const uploadDetails = await this.getUploadDetails(contextJwt);
624
+ // Step 2: Upload to S3
625
+ const s3Info = await this.uploadToS3ForTestCycle(uploadDetails, request.projectId, testCycleId, request.userAccountId, request.file, request.fileName);
626
+ // Step 3: Save metadata
627
+ return await this.saveTestCycleAttachmentMetadata(contextJwt, request.projectId, testCycleId, request.userAccountId, s3Info.s3Key, s3Info.fileName, s3Info.mimeType, s3Info.fileSize);
628
+ }
629
+ /**
630
+ * Get attachments for a test plan using private API
631
+ *
632
+ * Retrieves all attachment details for a test plan, including signed S3 URLs
633
+ * for downloading the attachments.
634
+ *
635
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
636
+ * The endpoint may change or be removed at any time without notice.
637
+ *
638
+ * @param credentials - Private API credentials
639
+ * @param request - Get attachments request
640
+ * @param request.testPlanKey - Test plan key (e.g., 'PROJ-P1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
641
+ * @param request.projectId - Jira project ID (numeric, not the project key)
642
+ * @returns Test plan attachments response with array of attachment details
643
+ * @throws {BadRequestError} If the request is invalid
644
+ * @throws {UnauthorizedError} If authentication fails
645
+ * @throws {ForbiddenError} If the user doesn't have permission
646
+ * @throws {NotFoundError} If the test plan is not found
647
+ * @throws {ServerError} If the server returns an error
648
+ * @throws {UnexpectedError} If test plan ID cannot be looked up from key and Zephyr Connector is not available
649
+ */
650
+ async getTestPlanAttachments(credentials, request) {
651
+ // Get test plan ID from key if we have API connection
652
+ let testPlanId;
653
+ if (this.testPlanGroup) {
654
+ try {
655
+ const testPlan = await this.testPlanGroup.getTestPlan({ testPlanIdOrKey: request.testPlanKey });
656
+ if (!testPlan) {
657
+ throw new NotFoundError(`Test plan with key '${request.testPlanKey}' not found.`);
658
+ }
659
+ testPlanId = testPlan.id;
660
+ }
661
+ catch (error) {
662
+ if (error instanceof NotFoundError) {
663
+ throw new NotFoundError(`Test plan with key '${request.testPlanKey}' not found.`);
664
+ }
665
+ throw new UnexpectedError(`Failed to look up test plan ID from key '${request.testPlanKey}'. Ensure Zephyr Connector is configured.`, error);
666
+ }
667
+ }
668
+ else {
669
+ throw new UnexpectedError('Cannot look up test plan ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
670
+ }
671
+ // Get Context JWT
672
+ const contextJwt = await this.getContextJwt(credentials);
673
+ const url = `${this.privateApiBaseUrl}/testplan/${testPlanId}?fields=attachments`;
674
+ const headers = {
675
+ 'accept': 'application/json',
676
+ 'authorization': `JWT ${contextJwt}`,
677
+ 'jira-project-id': String(request.projectId),
678
+ };
679
+ try {
680
+ const response = await fetch(url, {
681
+ method: 'GET',
682
+ headers,
683
+ });
684
+ if (!response.ok) {
685
+ if (response.status === 400) {
686
+ throw new BadRequestError('Invalid request parameters for getting attachments.');
687
+ }
688
+ if (response.status === 401) {
689
+ throw new UnauthorizedError('Failed to authenticate. Please check your credentials.');
690
+ }
691
+ if (response.status === 403) {
692
+ throw new ForbiddenError('Insufficient permissions to get attachments.');
693
+ }
694
+ if (response.status === 404) {
695
+ throw new NotFoundError(`Test plan with key '${request.testPlanKey}' not found.`);
696
+ }
697
+ throw new ServerError(`Failed to get test plan attachments. Status: ${response.status}`, response.status, response.statusText);
698
+ }
699
+ return await response.json();
700
+ }
701
+ catch (error) {
702
+ if (error instanceof BadRequestError ||
703
+ error instanceof UnauthorizedError ||
704
+ error instanceof ForbiddenError ||
705
+ error instanceof NotFoundError ||
706
+ error instanceof ServerError ||
707
+ error instanceof UnexpectedError) {
708
+ throw error;
709
+ }
710
+ throw new UnexpectedError('Unexpected error while getting test plan attachments', error);
711
+ }
712
+ }
713
+ /**
714
+ * Download an attachment from a test plan using private API
715
+ *
716
+ * Downloads an attachment file into memory. This method:
717
+ * 1. Gets fresh attachment details (including a fresh signed S3 URL)
718
+ * 2. Downloads the file from the signed URL
719
+ * 3. Returns the file as a Blob
720
+ *
721
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
722
+ * The endpoints may change or be removed at any time without notice.
723
+ *
724
+ * @param credentials - Private API credentials
725
+ * @param request - Download attachment request
726
+ * @param request.testPlanKey - Test plan key (e.g., 'PROJ-P1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
727
+ * @param request.projectId - Jira project ID (numeric, not the project key)
728
+ * @param request.attachmentId - Attachment ID (UUID string)
729
+ * @returns Blob containing the attachment file data
730
+ * @throws {BadRequestError} If the request is invalid
731
+ * @throws {UnauthorizedError} If authentication fails
732
+ * @throws {ForbiddenError} If the user doesn't have permission
733
+ * @throws {NotFoundError} If the test plan or attachment is not found
734
+ * @throws {ServerError} If the server returns an error
735
+ * @throws {UnexpectedError} If test plan ID cannot be looked up from key and Zephyr Connector is not available, or if download fails
736
+ */
737
+ async downloadTestPlanAttachment(credentials, request) {
738
+ // Step 1: Get fresh attachment details (including fresh signed URL)
739
+ const attachmentsResponse = await this.getTestPlanAttachments(credentials, {
740
+ testPlanKey: request.testPlanKey,
741
+ projectId: request.projectId,
742
+ });
743
+ // Step 2: Find the requested attachment
744
+ const attachment = attachmentsResponse.attachments.find((att) => att.id === request.attachmentId);
745
+ if (!attachment) {
746
+ throw new NotFoundError(`Attachment with ID '${request.attachmentId}' not found for test plan '${request.testPlanKey}'.`);
747
+ }
748
+ // Step 3: Download the file from the signed S3 URL
749
+ try {
750
+ const downloadResponse = await fetch(attachment.url, {
751
+ method: 'GET',
752
+ });
753
+ if (!downloadResponse.ok) {
754
+ if (downloadResponse.status === 403) {
755
+ throw new ForbiddenError('Failed to download attachment. The signed URL may have expired. Please try again.');
756
+ }
757
+ if (downloadResponse.status === 404) {
758
+ throw new NotFoundError('Attachment file not found on S3.');
759
+ }
760
+ throw new ServerError(`Failed to download attachment. Status: ${downloadResponse.status}`, downloadResponse.status, downloadResponse.statusText);
761
+ }
762
+ // Return as Blob
763
+ return await downloadResponse.blob();
764
+ }
765
+ catch (error) {
766
+ if (error instanceof ForbiddenError ||
767
+ error instanceof NotFoundError ||
768
+ error instanceof ServerError) {
769
+ throw error;
770
+ }
771
+ throw new UnexpectedError('Unexpected error while downloading attachment', error);
772
+ }
773
+ }
774
+ /**
775
+ * Create an attachment for a test plan using private API
776
+ *
777
+ * Uploads a file attachment to a test plan. This involves:
778
+ * 1. Getting upload details (S3 credentials)
779
+ * 2. Uploading the file to S3
780
+ * 3. Saving attachment metadata
781
+ *
782
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
783
+ * The endpoints may change or be removed at any time without notice.
784
+ *
785
+ * @param credentials - Private API credentials
786
+ * @param request - Attachment creation request
787
+ * @param request.testPlanKey - Test plan key (e.g., 'PROJ-P1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
788
+ * @param request.projectId - Jira project ID (numeric, not the project key)
789
+ * @param request.file - File to upload (Blob or ArrayBuffer)
790
+ * @param request.fileName - Name of the file
791
+ * @param request.userAccountId - Atlassian account ID (e.g., '5d6fdc98dc6e480dbc021aae')
792
+ * @returns Attachment metadata response
793
+ * @throws {BadRequestError} If the request is invalid
794
+ * @throws {UnauthorizedError} If authentication fails
795
+ * @throws {ForbiddenError} If the user doesn't have permission
796
+ * @throws {NotFoundError} If the test plan is not found
797
+ * @throws {ServerError} If the server returns an error
798
+ * @throws {UnexpectedError} If test plan ID cannot be looked up from key and Zephyr Connector is not available
799
+ */
800
+ async createTestPlanAttachment(credentials, request) {
801
+ // Get test plan ID from key if we have API connection
802
+ let testPlanId;
803
+ if (this.testPlanGroup) {
804
+ try {
805
+ const testPlan = await this.testPlanGroup.getTestPlan({ testPlanIdOrKey: request.testPlanKey });
806
+ if (!testPlan) {
807
+ throw new NotFoundError(`Test plan with key '${request.testPlanKey}' not found.`);
808
+ }
809
+ testPlanId = testPlan.id;
810
+ }
811
+ catch (error) {
812
+ if (error instanceof NotFoundError) {
813
+ throw new NotFoundError(`Test plan with key '${request.testPlanKey}' not found.`);
814
+ }
815
+ throw new UnexpectedError(`Failed to look up test plan ID from key '${request.testPlanKey}'. Ensure Zephyr Connector is configured.`, error);
816
+ }
817
+ }
818
+ else {
819
+ throw new UnexpectedError('Cannot look up test plan ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
820
+ }
821
+ // Get Context JWT
822
+ const contextJwt = await this.getContextJwt(credentials);
823
+ // Step 1: Get upload details
824
+ const uploadDetails = await this.getUploadDetails(contextJwt);
825
+ // Step 2: Upload to S3
826
+ const s3Info = await this.uploadToS3ForTestPlan(uploadDetails, request.projectId, testPlanId, request.userAccountId, request.file, request.fileName);
827
+ // Step 3: Save metadata
828
+ return await this.saveTestPlanAttachmentMetadata(contextJwt, request.projectId, testPlanId, request.userAccountId, s3Info.s3Key, s3Info.fileName, s3Info.mimeType, s3Info.fileSize);
829
+ }
830
+ /**
831
+ * Upload file to S3 for test cycle
832
+ *
833
+ * Uploads a file to S3 using the credentials from upload details.
834
+ * Uses "testrun" in the S3 key path.
835
+ *
836
+ * @param upload - Upload details from getUploadDetails
837
+ * @param projectId - Jira project ID
838
+ * @param testCycleId - Numeric test cycle ID
839
+ * @param userAccountId - Atlassian account ID
840
+ * @param file - File to upload (Blob or ArrayBuffer)
841
+ * @param fileName - Name of the file
842
+ * @returns S3 upload information
843
+ * @throws {UnexpectedError} If upload fails
844
+ */
845
+ async uploadToS3ForTestCycle(upload, projectId, testCycleId, userAccountId, file, fileName) {
846
+ const bucketUrl = upload.bucketUrl;
847
+ const keyPrefix = upload.keyPrefix;
848
+ const credential = upload.credential;
849
+ const date = upload.date;
850
+ const policy = upload.policy;
851
+ const signature = upload.signature;
852
+ // Generate UUID for file
853
+ const fileUuid = this.generateUUID();
854
+ // Note: Test cycles use "testrun" in the S3 key path, and the keyPrefix includes "write/"
855
+ const s3Key = `${keyPrefix}/project/${projectId}/testrun/${testCycleId}/${fileUuid}`;
856
+ // Determine MIME type
857
+ const mimeType = this.getMimeType(fileName);
858
+ // Get file size
859
+ const fileSize = file instanceof Blob ? file.size : file.byteLength;
860
+ // Create form data
861
+ const formData = new FormData();
862
+ formData.append('key', s3Key);
863
+ formData.append('acl', 'private');
864
+ formData.append('Policy', policy);
865
+ formData.append('X-Amz-Credential', credential);
866
+ formData.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
867
+ formData.append('X-Amz-Date', date);
868
+ formData.append('X-Amz-Signature', signature);
869
+ formData.append('X-Amz-Meta-user-account-id', userAccountId);
870
+ formData.append('X-Amz-Meta-name', fileName);
871
+ formData.append('Content-Type', mimeType);
872
+ // Add file
873
+ if (file instanceof Blob) {
874
+ formData.append('file', file, fileName);
875
+ }
876
+ else {
877
+ formData.append('file', new Blob([file], { type: mimeType }), fileName);
878
+ }
879
+ try {
880
+ const response = await fetch(bucketUrl, {
881
+ method: 'POST',
882
+ body: formData,
883
+ });
884
+ if (!response.ok && response.status !== 200 && response.status !== 201 && response.status !== 204) {
885
+ const errorText = await response.text().catch(() => 'Upload failed');
886
+ throw new UnexpectedError(`Failed to upload file to S3: ${errorText}`);
887
+ }
888
+ return {
889
+ s3Key,
890
+ fileName,
891
+ mimeType,
892
+ fileSize,
893
+ };
894
+ }
895
+ catch (error) {
896
+ if (error instanceof UnexpectedError) {
897
+ throw error;
898
+ }
899
+ throw new UnexpectedError('Unexpected error while uploading file to S3', error);
900
+ }
901
+ }
902
+ /**
903
+ * Save test cycle attachment metadata
904
+ *
905
+ * Saves metadata for an uploaded test cycle attachment.
906
+ *
907
+ * @param contextJwt - Jira Context JWT token
908
+ * @param projectId - Jira project ID
909
+ * @param testCycleId - Numeric test cycle ID
910
+ * @param userAccountId - Atlassian account ID
911
+ * @param s3Key - S3 key from upload
912
+ * @param fileName - File name
913
+ * @param mimeType - MIME type
914
+ * @param fileSize - File size in bytes
915
+ * @returns Attachment metadata response
916
+ * @throws {BadRequestError} If the request is invalid
917
+ * @throws {UnauthorizedError} If authentication fails
918
+ * @throws {ServerError} If the server returns an error
919
+ */
920
+ async saveTestCycleAttachmentMetadata(contextJwt, projectId, testCycleId, userAccountId, s3Key, fileName, mimeType, fileSize) {
921
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/attachment/metadata';
922
+ const headers = {
923
+ 'Authorization': `JWT ${contextJwt}`,
924
+ 'jira-project-id': String(projectId),
925
+ 'Content-Type': 'application/json',
926
+ 'Accept': 'application/json',
927
+ };
928
+ const createdOn = new Date().toISOString();
929
+ const payload = {
930
+ createdOn,
931
+ mimeType,
932
+ name: fileName,
933
+ s3Key,
934
+ size: fileSize,
935
+ testCycleId,
936
+ userAccountId,
937
+ };
938
+ try {
939
+ const response = await fetch(url, {
940
+ method: 'POST',
941
+ headers,
942
+ body: JSON.stringify(payload),
943
+ });
944
+ if (!response.ok) {
945
+ if (response.status === 400) {
946
+ const errorText = await response.text().catch(() => 'Bad Request');
947
+ throw new BadRequestError(`Failed to save attachment metadata: ${errorText}`, response.statusText);
948
+ }
949
+ if (response.status === 401) {
950
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
951
+ }
952
+ throw new ServerError(`Failed to save attachment metadata. Status: ${response.status}`, response.status, response.statusText);
953
+ }
954
+ return await response.json();
955
+ }
956
+ catch (error) {
957
+ if (error instanceof BadRequestError ||
958
+ error instanceof UnauthorizedError ||
959
+ error instanceof ServerError) {
960
+ throw error;
961
+ }
962
+ throw new UnexpectedError('Unexpected error while saving attachment metadata', error);
963
+ }
964
+ }
965
+ /**
966
+ * Upload file to S3 for test plan
967
+ *
968
+ * Uploads a file to S3 using the credentials from upload details.
969
+ * Uses "testplan" in the S3 key path.
970
+ *
971
+ * @param upload - Upload details from getUploadDetails
972
+ * @param projectId - Jira project ID
973
+ * @param testPlanId - Numeric test plan ID
974
+ * @param userAccountId - Atlassian account ID
975
+ * @param file - File to upload (Blob or ArrayBuffer)
976
+ * @param fileName - Name of the file
977
+ * @returns S3 upload information
978
+ * @throws {UnexpectedError} If upload fails
979
+ */
980
+ async uploadToS3ForTestPlan(upload, projectId, testPlanId, userAccountId, file, fileName) {
981
+ const bucketUrl = upload.bucketUrl;
982
+ const keyPrefix = upload.keyPrefix;
983
+ const credential = upload.credential;
984
+ const date = upload.date;
985
+ const policy = upload.policy;
986
+ const signature = upload.signature;
987
+ // Generate UUID for file
988
+ const fileUuid = this.generateUUID();
989
+ // Note: Test plans use "testplan" in the S3 key path, and the keyPrefix includes "write/"
990
+ const s3Key = `${keyPrefix}/project/${projectId}/testplan/${testPlanId}/${fileUuid}`;
991
+ // Determine MIME type
992
+ const mimeType = this.getMimeType(fileName);
993
+ // Get file size
994
+ const fileSize = file instanceof Blob ? file.size : file.byteLength;
995
+ // Create form data
996
+ const formData = new FormData();
997
+ formData.append('key', s3Key);
998
+ formData.append('acl', 'private');
999
+ formData.append('Policy', policy);
1000
+ formData.append('X-Amz-Credential', credential);
1001
+ formData.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
1002
+ formData.append('X-Amz-Date', date);
1003
+ formData.append('X-Amz-Signature', signature);
1004
+ formData.append('X-Amz-Meta-user-account-id', userAccountId);
1005
+ formData.append('X-Amz-Meta-name', fileName);
1006
+ formData.append('Content-Type', mimeType);
1007
+ // Add file
1008
+ if (file instanceof Blob) {
1009
+ formData.append('file', file, fileName);
1010
+ }
1011
+ else {
1012
+ formData.append('file', new Blob([file], { type: mimeType }), fileName);
1013
+ }
1014
+ try {
1015
+ const response = await fetch(bucketUrl, {
1016
+ method: 'POST',
1017
+ body: formData,
1018
+ });
1019
+ if (!response.ok && response.status !== 200 && response.status !== 201 && response.status !== 204) {
1020
+ const errorText = await response.text().catch(() => 'Upload failed');
1021
+ throw new UnexpectedError(`Failed to upload file to S3: ${errorText}`);
1022
+ }
1023
+ return {
1024
+ s3Key,
1025
+ fileName,
1026
+ mimeType,
1027
+ fileSize,
1028
+ };
1029
+ }
1030
+ catch (error) {
1031
+ if (error instanceof UnexpectedError) {
1032
+ throw error;
1033
+ }
1034
+ throw new UnexpectedError('Unexpected error while uploading file to S3', error);
1035
+ }
1036
+ }
1037
+ /**
1038
+ * Save test plan attachment metadata
1039
+ *
1040
+ * Saves metadata for an uploaded test plan attachment.
1041
+ *
1042
+ * @param contextJwt - Jira Context JWT token
1043
+ * @param projectId - Jira project ID
1044
+ * @param testPlanId - Numeric test plan ID
1045
+ * @param userAccountId - Atlassian account ID
1046
+ * @param s3Key - S3 key from upload
1047
+ * @param fileName - File name
1048
+ * @param mimeType - MIME type
1049
+ * @param fileSize - File size in bytes
1050
+ * @returns Attachment metadata response
1051
+ * @throws {BadRequestError} If the request is invalid
1052
+ * @throws {UnauthorizedError} If authentication fails
1053
+ * @throws {ServerError} If the server returns an error
1054
+ */
1055
+ async saveTestPlanAttachmentMetadata(contextJwt, projectId, testPlanId, userAccountId, s3Key, fileName, mimeType, fileSize) {
1056
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/attachment/metadata';
1057
+ const headers = {
1058
+ 'Authorization': `JWT ${contextJwt}`,
1059
+ 'jira-project-id': String(projectId),
1060
+ 'Content-Type': 'application/json',
1061
+ 'Accept': 'application/json',
1062
+ };
1063
+ const createdOn = new Date().toISOString();
1064
+ const payload = {
1065
+ createdOn,
1066
+ mimeType,
1067
+ name: fileName,
1068
+ s3Key,
1069
+ size: fileSize,
1070
+ testPlanId,
1071
+ userAccountId,
1072
+ };
1073
+ try {
1074
+ const response = await fetch(url, {
1075
+ method: 'POST',
1076
+ headers,
1077
+ body: JSON.stringify(payload),
1078
+ });
1079
+ if (!response.ok) {
1080
+ if (response.status === 400) {
1081
+ const errorText = await response.text().catch(() => 'Bad Request');
1082
+ throw new BadRequestError(`Failed to save attachment metadata: ${errorText}`, response.statusText);
1083
+ }
1084
+ if (response.status === 401) {
1085
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
1086
+ }
1087
+ throw new ServerError(`Failed to save attachment metadata. Status: ${response.status}`, response.status, response.statusText);
1088
+ }
1089
+ return await response.json();
1090
+ }
1091
+ catch (error) {
1092
+ if (error instanceof BadRequestError ||
1093
+ error instanceof UnauthorizedError ||
1094
+ error instanceof ServerError) {
1095
+ throw error;
1096
+ }
1097
+ throw new UnexpectedError('Unexpected error while saving attachment metadata', error);
1098
+ }
1099
+ }
1100
+ /**
1101
+ * Get attachments for a test step using private API
1102
+ *
1103
+ * Retrieves all attachment details for a specific test step within a test case.
1104
+ * Attachments are retrieved by getting the test case with fields including testScript.steps.attachments.
1105
+ *
1106
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
1107
+ * The endpoint may change or be removed at any time without notice.
1108
+ *
1109
+ * @param credentials - Private API credentials
1110
+ * @param request - Get attachments request
1111
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1')
1112
+ * @param request.stepId - Numeric test step ID
1113
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1114
+ * @returns Test step attachments response with array of attachment details
1115
+ * @throws {BadRequestError} If the request is invalid
1116
+ * @throws {UnauthorizedError} If authentication fails
1117
+ * @throws {ForbiddenError} If the user doesn't have permission
1118
+ * @throws {NotFoundError} If the test case or step is not found
1119
+ * @throws {ServerError} If the server returns an error
1120
+ * @throws {UnexpectedError} If the test case cannot be retrieved
1121
+ */
1122
+ async getTestStepAttachments(credentials, request) {
1123
+ // Get Context JWT
1124
+ const contextJwt = await this.getContextJwt(credentials);
1125
+ // Get test case ID from key if we have API connection
1126
+ let testCaseId;
1127
+ if (this.testCaseGroup) {
1128
+ try {
1129
+ const testCase = await this.testCaseGroup.getTestCase({ testCaseKey: request.testCaseKey });
1130
+ if (!testCase) {
1131
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
1132
+ }
1133
+ testCaseId = testCase.id;
1134
+ }
1135
+ catch (error) {
1136
+ if (error instanceof NotFoundError) {
1137
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
1138
+ }
1139
+ throw new UnexpectedError(`Failed to look up test case ID from key '${request.testCaseKey}'. Ensure Zephyr Connector is configured.`, error);
1140
+ }
1141
+ }
1142
+ else {
1143
+ throw new UnexpectedError('Cannot look up test case ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
1144
+ }
1145
+ // Get test case with testScript.steps.attachments fields
1146
+ const fields = 'testScript(id,text,steps(index,reflectRef,description,text,expectedResult,testData,customFieldValues,attachments,id,stepParameters(id,testCaseParameterId,value),testCase(id,key,name,archived,majorVersion,latestVersion,projectKey,parameters(id,name,defaultValue,index))))';
1147
+ const url = `${this.privateApiBaseUrl}/testcase/${request.testCaseKey}?fields=${encodeURIComponent(fields)}`;
1148
+ const headers = {
1149
+ 'accept': 'application/json',
1150
+ 'authorization': `JWT ${contextJwt}`,
1151
+ 'jira-project-id': String(request.projectId),
1152
+ };
1153
+ try {
1154
+ const response = await fetch(url, {
1155
+ method: 'GET',
1156
+ headers,
1157
+ });
1158
+ if (!response.ok) {
1159
+ if (response.status === 400) {
1160
+ throw new BadRequestError('Invalid request parameters for getting attachments.');
1161
+ }
1162
+ if (response.status === 401) {
1163
+ throw new UnauthorizedError('Failed to authenticate. Please check your credentials.');
1164
+ }
1165
+ if (response.status === 403) {
1166
+ throw new ForbiddenError('Insufficient permissions to get attachments.');
1167
+ }
1168
+ if (response.status === 404) {
1169
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
1170
+ }
1171
+ throw new ServerError(`Failed to get test step attachments. Status: ${response.status}`, response.status, response.statusText);
1172
+ }
1173
+ const testCaseData = await response.json();
1174
+ // Find the specific step
1175
+ const step = testCaseData.testScript?.stepByStepScript?.steps?.find((s) => s.id === request.stepId);
1176
+ if (!step) {
1177
+ throw new NotFoundError(`Test step with ID ${request.stepId} not found in test case '${request.testCaseKey}'.`);
1178
+ }
1179
+ return {
1180
+ attachments: (step.attachments || []).map((att) => ({
1181
+ id: att.id,
1182
+ dbAttachmentId: att.dbAttachmentId,
1183
+ url: att.url,
1184
+ userKey: att.userKey,
1185
+ createdOn: att.createdOn,
1186
+ name: att.name,
1187
+ mimeType: att.mimeType,
1188
+ fileSize: att.fileSize,
1189
+ copyFromStep: att.copyFromStep,
1190
+ s3Key: att.s3Key,
1191
+ stepId: att.stepId,
1192
+ })),
1193
+ };
1194
+ }
1195
+ catch (error) {
1196
+ if (error instanceof BadRequestError ||
1197
+ error instanceof UnauthorizedError ||
1198
+ error instanceof ForbiddenError ||
1199
+ error instanceof NotFoundError ||
1200
+ error instanceof ServerError ||
1201
+ error instanceof UnexpectedError) {
1202
+ throw error;
1203
+ }
1204
+ throw new UnexpectedError('Unexpected error while getting test step attachments', error);
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Download an attachment from a test step using private API
1209
+ *
1210
+ * Downloads an attachment file into memory. This method:
1211
+ * 1. Gets fresh attachment details (including a fresh signed S3 URL)
1212
+ * 2. Downloads the file from the signed URL
1213
+ * 3. Returns the file as a Blob
1214
+ *
1215
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
1216
+ * The endpoints may change or be removed at any time without notice.
1217
+ *
1218
+ * @param credentials - Private API credentials
1219
+ * @param request - Download attachment request
1220
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1')
1221
+ * @param request.stepId - Numeric test step ID
1222
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1223
+ * @param request.attachmentId - Attachment ID (UUID string)
1224
+ * @returns Blob containing the attachment file data
1225
+ * @throws {BadRequestError} If the request is invalid
1226
+ * @throws {UnauthorizedError} If authentication fails
1227
+ * @throws {ForbiddenError} If the user doesn't have permission
1228
+ * @throws {NotFoundError} If the test case, step, or attachment is not found
1229
+ * @throws {ServerError} If the server returns an error
1230
+ * @throws {UnexpectedError} If download fails
1231
+ */
1232
+ async downloadTestStepAttachment(credentials, request) {
1233
+ // Step 1: Get fresh attachment details (including fresh signed URL)
1234
+ const attachmentsResponse = await this.getTestStepAttachments(credentials, {
1235
+ testCaseKey: request.testCaseKey,
1236
+ stepId: request.stepId,
1237
+ projectId: request.projectId,
1238
+ });
1239
+ // Step 2: Find the requested attachment
1240
+ const attachment = attachmentsResponse.attachments.find((att) => att.id === request.attachmentId);
1241
+ if (!attachment) {
1242
+ throw new NotFoundError(`Attachment with ID '${request.attachmentId}' not found for test step ${request.stepId} in test case '${request.testCaseKey}'.`);
1243
+ }
1244
+ // Step 3: Download the file from the signed S3 URL
1245
+ try {
1246
+ const downloadResponse = await fetch(attachment.url, {
1247
+ method: 'GET',
1248
+ });
1249
+ if (!downloadResponse.ok) {
1250
+ if (downloadResponse.status === 403) {
1251
+ throw new ForbiddenError('Failed to download attachment. The signed URL may have expired. Please try again.');
1252
+ }
1253
+ if (downloadResponse.status === 404) {
1254
+ throw new NotFoundError('Attachment file not found on S3.');
1255
+ }
1256
+ throw new ServerError(`Failed to download attachment. Status: ${downloadResponse.status}`, downloadResponse.status, downloadResponse.statusText);
1257
+ }
1258
+ // Return as Blob
1259
+ return await downloadResponse.blob();
1260
+ }
1261
+ catch (error) {
1262
+ if (error instanceof ForbiddenError ||
1263
+ error instanceof NotFoundError ||
1264
+ error instanceof ServerError) {
1265
+ throw error;
1266
+ }
1267
+ throw new UnexpectedError('Unexpected error while downloading attachment', error);
1268
+ }
1269
+ }
1270
+ /**
1271
+ * Create an attachment for a test step using private API
1272
+ *
1273
+ * Uploads a file attachment to a test step. This involves:
1274
+ * 1. Getting upload details (S3 credentials)
1275
+ * 2. Uploading the file to S3
1276
+ * 3. Saving attachment metadata
1277
+ *
1278
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
1279
+ * The endpoints may change or be removed at any time without notice.
1280
+ *
1281
+ * @param credentials - Private API credentials
1282
+ * @param request - Attachment creation request
1283
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1')
1284
+ * @param request.stepId - Numeric test step ID
1285
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1286
+ * @param request.file - File to upload (Blob or ArrayBuffer)
1287
+ * @param request.fileName - Name of the file
1288
+ * @param request.userAccountId - Atlassian account ID (e.g., '5d6fdc98dc6e480dbc021aae')
1289
+ * @returns Attachment metadata response
1290
+ * @throws {BadRequestError} If the request is invalid
1291
+ * @throws {UnauthorizedError} If authentication fails
1292
+ * @throws {ForbiddenError} If the user doesn't have permission
1293
+ * @throws {NotFoundError} If the test case or step is not found
1294
+ * @throws {ServerError} If the server returns an error
1295
+ */
1296
+ async createTestStepAttachment(credentials, request) {
1297
+ // Get Context JWT
1298
+ const contextJwt = await this.getContextJwt(credentials);
1299
+ // Step 1: Get upload details
1300
+ const uploadDetails = await this.getUploadDetails(contextJwt);
1301
+ // Step 2: Upload to S3
1302
+ const s3Info = await this.uploadToS3ForTestStep(uploadDetails, request.projectId, request.stepId, request.userAccountId, request.file, request.fileName);
1303
+ // Step 3: Save metadata
1304
+ return await this.saveTestStepAttachmentMetadata(contextJwt, request.projectId, request.stepId, request.userAccountId, s3Info.s3Key, s3Info.fileName, s3Info.mimeType, s3Info.fileSize);
1305
+ }
1306
+ /**
1307
+ * Get attachments for a test execution using private API
1308
+ *
1309
+ * Retrieves all attachment details for a test execution, including signed S3 URLs
1310
+ * for downloading the attachments.
1311
+ *
1312
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
1313
+ * The endpoint may change or be removed at any time without notice.
1314
+ *
1315
+ * @param credentials - Private API credentials
1316
+ * @param request - Get attachments request
1317
+ * @param request.testExecutionKey - Test execution key (e.g., 'PROJ-E1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
1318
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1319
+ * @returns Test execution attachments response with array of attachment details
1320
+ * @throws {BadRequestError} If the request is invalid
1321
+ * @throws {UnauthorizedError} If authentication fails
1322
+ * @throws {ForbiddenError} If the user doesn't have permission
1323
+ * @throws {NotFoundError} If the test execution is not found
1324
+ * @throws {ServerError} If the server returns an error
1325
+ * @throws {UnexpectedError} If test execution ID cannot be looked up from key and Zephyr Connector is not available
1326
+ */
1327
+ async getTestExecutionAttachments(credentials, request) {
1328
+ // Get test execution ID from key if we have API connection
1329
+ let testExecutionId;
1330
+ if (this.testExecutionGroup) {
1331
+ try {
1332
+ const testExecution = await this.testExecutionGroup.getTestExecution({
1333
+ testExecutionIdOrKey: request.testExecutionKey,
1334
+ });
1335
+ testExecutionId = testExecution.id;
1336
+ }
1337
+ catch (error) {
1338
+ if (error instanceof NotFoundError) {
1339
+ throw new NotFoundError(`Test execution with key '${request.testExecutionKey}' not found.`);
1340
+ }
1341
+ throw new UnexpectedError(`Failed to look up test execution ID from key '${request.testExecutionKey}'. Ensure Zephyr Connector is configured.`, error);
1342
+ }
1343
+ }
1344
+ else {
1345
+ throw new UnexpectedError('Cannot look up test execution ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
1346
+ }
1347
+ // Get Context JWT
1348
+ const contextJwt = await this.getContextJwt(credentials);
1349
+ const url = `${this.privateApiBaseUrl}/testresult/${request.testExecutionKey}?fields=attachments&itemId=${request.testExecutionKey}`;
1350
+ const headers = {
1351
+ 'accept': 'application/json',
1352
+ 'authorization': `JWT ${contextJwt}`,
1353
+ 'jira-project-id': String(request.projectId),
1354
+ };
1355
+ try {
1356
+ const response = await fetch(url, {
1357
+ method: 'GET',
1358
+ headers,
1359
+ });
1360
+ if (!response.ok) {
1361
+ if (response.status === 400) {
1362
+ throw new BadRequestError('Invalid request parameters for getting attachments.');
1363
+ }
1364
+ if (response.status === 401) {
1365
+ throw new UnauthorizedError('Failed to authenticate. Please check your credentials.');
1366
+ }
1367
+ if (response.status === 403) {
1368
+ throw new ForbiddenError('Insufficient permissions to get attachments.');
1369
+ }
1370
+ if (response.status === 404) {
1371
+ throw new NotFoundError(`Test execution with key '${request.testExecutionKey}' not found.`);
1372
+ }
1373
+ throw new ServerError(`Failed to get test execution attachments. Status: ${response.status}`, response.status, response.statusText);
1374
+ }
1375
+ const executionData = await response.json();
1376
+ return {
1377
+ attachments: (executionData.attachments || []).map((att) => ({
1378
+ id: att.id,
1379
+ dbAttachmentId: att.dbAttachmentId,
1380
+ url: att.url,
1381
+ userKey: att.userKey,
1382
+ createdOn: att.createdOn,
1383
+ name: att.name,
1384
+ mimeType: att.mimeType,
1385
+ fileSize: att.fileSize,
1386
+ copyFromStep: att.copyFromStep,
1387
+ s3Key: att.s3Key,
1388
+ testExecutionId: att.testExecutionId,
1389
+ })),
1390
+ };
1391
+ }
1392
+ catch (error) {
1393
+ if (error instanceof BadRequestError ||
1394
+ error instanceof UnauthorizedError ||
1395
+ error instanceof ForbiddenError ||
1396
+ error instanceof NotFoundError ||
1397
+ error instanceof ServerError ||
1398
+ error instanceof UnexpectedError) {
1399
+ throw error;
1400
+ }
1401
+ throw new UnexpectedError('Unexpected error while getting test execution attachments', error);
1402
+ }
1403
+ }
1404
+ /**
1405
+ * Download an attachment from a test execution using private API
1406
+ *
1407
+ * Downloads an attachment file into memory. This method:
1408
+ * 1. Gets fresh attachment details (including a fresh signed S3 URL)
1409
+ * 2. Downloads the file from the signed URL
1410
+ * 3. Returns the file as a Blob
1411
+ *
1412
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
1413
+ * The endpoints may change or be removed at any time without notice.
1414
+ *
1415
+ * @param credentials - Private API credentials
1416
+ * @param request - Download attachment request
1417
+ * @param request.testExecutionKey - Test execution key (e.g., 'PROJ-E1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
1418
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1419
+ * @param request.attachmentId - Attachment ID (UUID string)
1420
+ * @returns Blob containing the attachment file data
1421
+ * @throws {BadRequestError} If the request is invalid
1422
+ * @throws {UnauthorizedError} If authentication fails
1423
+ * @throws {ForbiddenError} If the user doesn't have permission
1424
+ * @throws {NotFoundError} If the test execution or attachment is not found
1425
+ * @throws {ServerError} If the server returns an error
1426
+ * @throws {UnexpectedError} If test execution ID cannot be looked up from key and Zephyr Connector is not available, or if download fails
1427
+ */
1428
+ async downloadTestExecutionAttachment(credentials, request) {
1429
+ // Step 1: Get fresh attachment details (including fresh signed URL)
1430
+ const attachmentsResponse = await this.getTestExecutionAttachments(credentials, {
1431
+ testExecutionKey: request.testExecutionKey,
1432
+ projectId: request.projectId,
1433
+ });
1434
+ // Step 2: Find the requested attachment
1435
+ const attachment = attachmentsResponse.attachments.find((att) => att.id === request.attachmentId);
1436
+ if (!attachment) {
1437
+ throw new NotFoundError(`Attachment with ID '${request.attachmentId}' not found for test execution '${request.testExecutionKey}'.`);
1438
+ }
1439
+ // Step 3: Download the file from the signed S3 URL
1440
+ try {
1441
+ const downloadResponse = await fetch(attachment.url, {
1442
+ method: 'GET',
1443
+ });
1444
+ if (!downloadResponse.ok) {
1445
+ if (downloadResponse.status === 403) {
1446
+ throw new ForbiddenError('Failed to download attachment. The signed URL may have expired. Please try again.');
1447
+ }
1448
+ if (downloadResponse.status === 404) {
1449
+ throw new NotFoundError('Attachment file not found on S3.');
1450
+ }
1451
+ throw new ServerError(`Failed to download attachment. Status: ${downloadResponse.status}`, downloadResponse.status, downloadResponse.statusText);
1452
+ }
1453
+ // Return as Blob
1454
+ return await downloadResponse.blob();
1455
+ }
1456
+ catch (error) {
1457
+ if (error instanceof ForbiddenError ||
1458
+ error instanceof NotFoundError ||
1459
+ error instanceof ServerError) {
1460
+ throw error;
1461
+ }
1462
+ throw new UnexpectedError('Unexpected error while downloading attachment', error);
1463
+ }
1464
+ }
1465
+ /**
1466
+ * Create an attachment for a test execution using private API
1467
+ *
1468
+ * Uploads a file attachment to a test execution. This involves:
1469
+ * 1. Getting upload details (S3 credentials)
1470
+ * 2. Uploading the file to S3
1471
+ * 3. Saving attachment metadata
1472
+ *
1473
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
1474
+ * The endpoints may change or be removed at any time without notice.
1475
+ *
1476
+ * @param credentials - Private API credentials
1477
+ * @param request - Attachment creation request
1478
+ * @param request.testExecutionKey - Test execution key (e.g., 'PROJ-E1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
1479
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1480
+ * @param request.file - File to upload (Blob or ArrayBuffer)
1481
+ * @param request.fileName - Name of the file
1482
+ * @param request.userAccountId - Atlassian account ID (e.g., '5d6fdc98dc6e480dbc021aae')
1483
+ * @returns Attachment metadata response
1484
+ * @throws {BadRequestError} If the request is invalid
1485
+ * @throws {UnauthorizedError} If authentication fails
1486
+ * @throws {ForbiddenError} If the user doesn't have permission
1487
+ * @throws {NotFoundError} If the test execution is not found
1488
+ * @throws {ServerError} If the server returns an error
1489
+ * @throws {UnexpectedError} If test execution ID cannot be looked up from key and Zephyr Connector is not available
1490
+ */
1491
+ async createTestExecutionAttachment(credentials, request) {
1492
+ // Get test execution ID from key if we have API connection
1493
+ let testExecutionId;
1494
+ if (this.testExecutionGroup) {
1495
+ try {
1496
+ const testExecution = await this.testExecutionGroup.getTestExecution({
1497
+ testExecutionIdOrKey: request.testExecutionKey,
1498
+ });
1499
+ testExecutionId = testExecution.id;
1500
+ }
1501
+ catch (error) {
1502
+ if (error instanceof NotFoundError) {
1503
+ throw new NotFoundError(`Test execution with key '${request.testExecutionKey}' not found.`);
1504
+ }
1505
+ throw new UnexpectedError(`Failed to look up test execution ID from key '${request.testExecutionKey}'. Ensure Zephyr Connector is configured.`, error);
1506
+ }
1507
+ }
1508
+ else {
1509
+ throw new UnexpectedError('Cannot look up test execution ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
1510
+ }
1511
+ // Get Context JWT
1512
+ const contextJwt = await this.getContextJwt(credentials);
1513
+ // Step 1: Get upload details
1514
+ const uploadDetails = await this.getUploadDetails(contextJwt);
1515
+ // Step 2: Upload to S3
1516
+ const s3Info = await this.uploadToS3ForTestExecution(uploadDetails, request.projectId, testExecutionId, request.userAccountId, request.file, request.fileName);
1517
+ // Step 3: Save metadata
1518
+ return await this.saveTestExecutionAttachmentMetadata(contextJwt, request.projectId, testExecutionId, request.userAccountId, s3Info.s3Key, s3Info.fileName, s3Info.mimeType, s3Info.fileSize);
1519
+ }
1520
+ /**
1521
+ * Get attachments for a test execution step using private API
1522
+ *
1523
+ * Retrieves all attachment details for a specific test execution step (testScriptResult).
1524
+ * Attachments are retrieved by getting the test execution with fields including testScriptResults.attachments.
1525
+ *
1526
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
1527
+ * The endpoint may change or be removed at any time without notice.
1528
+ *
1529
+ * @param credentials - Private API credentials
1530
+ * @param request - Get attachments request
1531
+ * @param request.testExecutionKey - Test execution key (e.g., 'PROJ-E1')
1532
+ * @param request.testScriptResultId - Numeric test script result ID
1533
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1534
+ * @returns Test execution step attachments response with array of attachment details
1535
+ * @throws {BadRequestError} If the request is invalid
1536
+ * @throws {UnauthorizedError} If authentication fails
1537
+ * @throws {ForbiddenError} If the user doesn't have permission
1538
+ * @throws {NotFoundError} If the test execution or step is not found
1539
+ * @throws {ServerError} If the server returns an error
1540
+ * @throws {UnexpectedError} If the test execution cannot be retrieved
1541
+ */
1542
+ async getTestExecutionStepAttachments(credentials, request) {
1543
+ // Get Context JWT
1544
+ const contextJwt = await this.getContextJwt(credentials);
1545
+ const url = `${this.privateApiBaseUrl}/testresult/${request.testExecutionKey}?fields=testScriptResults(id,testResultStatusId,executionDate,comment,index,description,expectedResult,testData,traceLinks,attachments,sourceScriptType,parameterSetId,customFieldValues,stepAttachmentsMapping,reflectRef),attachments&itemId=${request.testExecutionKey}`;
1546
+ const headers = {
1547
+ 'accept': 'application/json',
1548
+ 'authorization': `JWT ${contextJwt}`,
1549
+ 'jira-project-id': String(request.projectId),
1550
+ };
1551
+ try {
1552
+ const response = await fetch(url, {
1553
+ method: 'GET',
1554
+ headers,
1555
+ });
1556
+ if (!response.ok) {
1557
+ if (response.status === 400) {
1558
+ throw new BadRequestError('Invalid request parameters for getting attachments.');
1559
+ }
1560
+ if (response.status === 401) {
1561
+ throw new UnauthorizedError('Failed to authenticate. Please check your credentials.');
1562
+ }
1563
+ if (response.status === 403) {
1564
+ throw new ForbiddenError('Insufficient permissions to get attachments.');
1565
+ }
1566
+ if (response.status === 404) {
1567
+ throw new NotFoundError(`Test execution with key '${request.testExecutionKey}' not found.`);
1568
+ }
1569
+ throw new ServerError(`Failed to get test execution step attachments. Status: ${response.status}`, response.status, response.statusText);
1570
+ }
1571
+ const executionData = await response.json();
1572
+ // Find the specific testScriptResult
1573
+ const step = executionData.testScriptResults?.find((s) => s.id === request.testScriptResultId);
1574
+ if (!step) {
1575
+ throw new NotFoundError(`Test execution step with ID ${request.testScriptResultId} not found in test execution '${request.testExecutionKey}'.`);
1576
+ }
1577
+ return {
1578
+ attachments: (step.attachments || []).map((att) => ({
1579
+ id: att.id,
1580
+ dbAttachmentId: att.dbAttachmentId,
1581
+ url: att.url,
1582
+ userKey: att.userKey,
1583
+ createdOn: att.createdOn,
1584
+ name: att.name,
1585
+ mimeType: att.mimeType,
1586
+ fileSize: att.fileSize,
1587
+ copyFromStep: att.copyFromStep,
1588
+ s3Key: att.s3Key,
1589
+ testScriptResultId: att.testScriptResultId,
1590
+ })),
1591
+ };
1592
+ }
1593
+ catch (error) {
1594
+ if (error instanceof BadRequestError ||
1595
+ error instanceof UnauthorizedError ||
1596
+ error instanceof ForbiddenError ||
1597
+ error instanceof NotFoundError ||
1598
+ error instanceof ServerError ||
1599
+ error instanceof UnexpectedError) {
1600
+ throw error;
1601
+ }
1602
+ throw new UnexpectedError('Unexpected error while getting test execution step attachments', error);
1603
+ }
1604
+ }
1605
+ /**
1606
+ * Download an attachment from a test execution step using private API
1607
+ *
1608
+ * Downloads an attachment file into memory. This method:
1609
+ * 1. Gets fresh attachment details (including a fresh signed S3 URL)
1610
+ * 2. Downloads the file from the signed URL
1611
+ * 3. Returns the file as a Blob
1612
+ *
1613
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
1614
+ * The endpoints may change or be removed at any time without notice.
1615
+ *
1616
+ * @param credentials - Private API credentials
1617
+ * @param request - Download attachment request
1618
+ * @param request.testExecutionKey - Test execution key (e.g., 'PROJ-E1')
1619
+ * @param request.testScriptResultId - Numeric test script result ID
1620
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1621
+ * @param request.attachmentId - Attachment ID (UUID string)
1622
+ * @returns Blob containing the attachment file data
1623
+ * @throws {BadRequestError} If the request is invalid
1624
+ * @throws {UnauthorizedError} If authentication fails
1625
+ * @throws {ForbiddenError} If the user doesn't have permission
1626
+ * @throws {NotFoundError} If the test execution, step, or attachment is not found
1627
+ * @throws {ServerError} If the server returns an error
1628
+ * @throws {UnexpectedError} If download fails
1629
+ */
1630
+ async downloadTestExecutionStepAttachment(credentials, request) {
1631
+ // Step 1: Get fresh attachment details (including fresh signed URL)
1632
+ const attachmentsResponse = await this.getTestExecutionStepAttachments(credentials, {
1633
+ testExecutionKey: request.testExecutionKey,
1634
+ testScriptResultId: request.testScriptResultId,
1635
+ projectId: request.projectId,
1636
+ });
1637
+ // Step 2: Find the requested attachment
1638
+ const attachment = attachmentsResponse.attachments.find((att) => att.id === request.attachmentId);
1639
+ if (!attachment) {
1640
+ throw new NotFoundError(`Attachment with ID '${request.attachmentId}' not found for test execution step ${request.testScriptResultId} in test execution '${request.testExecutionKey}'.`);
1641
+ }
1642
+ // Step 3: Download the file from the signed S3 URL
1643
+ try {
1644
+ const downloadResponse = await fetch(attachment.url, {
1645
+ method: 'GET',
1646
+ });
1647
+ if (!downloadResponse.ok) {
1648
+ if (downloadResponse.status === 403) {
1649
+ throw new ForbiddenError('Failed to download attachment. The signed URL may have expired. Please try again.');
1650
+ }
1651
+ if (downloadResponse.status === 404) {
1652
+ throw new NotFoundError('Attachment file not found on S3.');
1653
+ }
1654
+ throw new ServerError(`Failed to download attachment. Status: ${downloadResponse.status}`, downloadResponse.status, downloadResponse.statusText);
1655
+ }
1656
+ // Return as Blob
1657
+ return await downloadResponse.blob();
1658
+ }
1659
+ catch (error) {
1660
+ if (error instanceof ForbiddenError ||
1661
+ error instanceof NotFoundError ||
1662
+ error instanceof ServerError) {
1663
+ throw error;
1664
+ }
1665
+ throw new UnexpectedError('Unexpected error while downloading attachment', error);
1666
+ }
1667
+ }
1668
+ /**
1669
+ * Create an attachment for a test execution step using private API
1670
+ *
1671
+ * Uploads a file attachment to a test execution step. This involves:
1672
+ * 1. Getting upload details (S3 credentials)
1673
+ * 2. Uploading the file to S3
1674
+ * 3. Saving attachment metadata
1675
+ *
1676
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
1677
+ * The endpoints may change or be removed at any time without notice.
1678
+ *
1679
+ * @param credentials - Private API credentials
1680
+ * @param request - Attachment creation request
1681
+ * @param request.testExecutionKey - Test execution key (e.g., 'PROJ-E1')
1682
+ * @param request.testScriptResultId - Numeric test script result ID
1683
+ * @param request.projectId - Jira project ID (numeric, not the project key)
1684
+ * @param request.file - File to upload (Blob or ArrayBuffer)
1685
+ * @param request.fileName - Name of the file
1686
+ * @param request.userAccountId - Atlassian account ID (e.g., '5d6fdc98dc6e480dbc021aae')
1687
+ * @returns Attachment metadata response
1688
+ * @throws {BadRequestError} If the request is invalid
1689
+ * @throws {UnauthorizedError} If authentication fails
1690
+ * @throws {ForbiddenError} If the user doesn't have permission
1691
+ * @throws {NotFoundError} If the test execution or step is not found
1692
+ * @throws {ServerError} If the server returns an error
1693
+ */
1694
+ async createTestExecutionStepAttachment(credentials, request) {
1695
+ // Get Context JWT
1696
+ const contextJwt = await this.getContextJwt(credentials);
1697
+ // Step 1: Get upload details
1698
+ const uploadDetails = await this.getUploadDetails(contextJwt);
1699
+ // Step 2: Upload to S3
1700
+ const s3Info = await this.uploadToS3ForTestExecutionStep(uploadDetails, request.projectId, request.testScriptResultId, request.userAccountId, request.file, request.fileName);
1701
+ // Step 3: Save metadata
1702
+ return await this.saveTestExecutionStepAttachmentMetadata(contextJwt, request.projectId, request.testScriptResultId, request.userAccountId, s3Info.s3Key, s3Info.fileName, s3Info.mimeType, s3Info.fileSize);
1703
+ }
1704
+ /**
1705
+ * Upload file to S3 for test step
1706
+ *
1707
+ * Uploads a file to S3 using the credentials from upload details.
1708
+ * Uses "step" in the S3 key path.
1709
+ *
1710
+ * @param upload - Upload details from getUploadDetails
1711
+ * @param projectId - Jira project ID
1712
+ * @param stepId - Numeric test step ID
1713
+ * @param userAccountId - Atlassian account ID
1714
+ * @param file - File to upload (Blob or ArrayBuffer)
1715
+ * @param fileName - Name of the file
1716
+ * @returns S3 upload information
1717
+ * @throws {UnexpectedError} If upload fails
1718
+ */
1719
+ async uploadToS3ForTestStep(upload, projectId, stepId, userAccountId, file, fileName) {
1720
+ const bucketUrl = upload.bucketUrl;
1721
+ const keyPrefix = upload.keyPrefix;
1722
+ const credential = upload.credential;
1723
+ const date = upload.date;
1724
+ const policy = upload.policy;
1725
+ const signature = upload.signature;
1726
+ // Generate UUID for file
1727
+ const fileUuid = this.generateUUID();
1728
+ // Note: Test steps use "step" in the S3 key path, and the keyPrefix includes "write/"
1729
+ const s3Key = `${keyPrefix}/project/${projectId}/step/${stepId}/${fileUuid}`;
1730
+ // Determine MIME type
1731
+ const mimeType = this.getMimeType(fileName);
1732
+ // Get file size
1733
+ const fileSize = file instanceof Blob ? file.size : file.byteLength;
1734
+ // Create form data
1735
+ const formData = new FormData();
1736
+ formData.append('key', s3Key);
1737
+ formData.append('acl', 'private');
1738
+ formData.append('Policy', policy);
1739
+ formData.append('X-Amz-Credential', credential);
1740
+ formData.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
1741
+ formData.append('X-Amz-Date', date);
1742
+ formData.append('X-Amz-Signature', signature);
1743
+ formData.append('X-Amz-Meta-user-account-id', userAccountId);
1744
+ formData.append('X-Amz-Meta-name', fileName);
1745
+ formData.append('Content-Type', mimeType);
1746
+ // Add file
1747
+ if (file instanceof Blob) {
1748
+ formData.append('file', file, fileName);
1749
+ }
1750
+ else {
1751
+ formData.append('file', new Blob([file], { type: mimeType }), fileName);
1752
+ }
1753
+ try {
1754
+ const response = await fetch(bucketUrl, {
1755
+ method: 'POST',
1756
+ body: formData,
1757
+ });
1758
+ if (!response.ok && response.status !== 200 && response.status !== 201 && response.status !== 204) {
1759
+ const errorText = await response.text().catch(() => 'Upload failed');
1760
+ throw new UnexpectedError(`Failed to upload file to S3: ${errorText}`);
1761
+ }
1762
+ return {
1763
+ s3Key,
1764
+ fileName,
1765
+ mimeType,
1766
+ fileSize,
1767
+ };
1768
+ }
1769
+ catch (error) {
1770
+ if (error instanceof UnexpectedError) {
1771
+ throw error;
1772
+ }
1773
+ throw new UnexpectedError('Unexpected error while uploading file to S3', error);
1774
+ }
1775
+ }
1776
+ /**
1777
+ * Save test step attachment metadata
1778
+ *
1779
+ * Saves metadata for an uploaded test step attachment.
1780
+ *
1781
+ * @param contextJwt - Jira Context JWT token
1782
+ * @param projectId - Jira project ID
1783
+ * @param stepId - Numeric test step ID
1784
+ * @param userAccountId - Atlassian account ID
1785
+ * @param s3Key - S3 key from upload
1786
+ * @param fileName - File name
1787
+ * @param mimeType - MIME type
1788
+ * @param fileSize - File size in bytes
1789
+ * @returns Attachment metadata response
1790
+ * @throws {BadRequestError} If the request is invalid
1791
+ * @throws {UnauthorizedError} If authentication fails
1792
+ * @throws {ServerError} If the server returns an error
1793
+ */
1794
+ async saveTestStepAttachmentMetadata(contextJwt, projectId, stepId, userAccountId, s3Key, fileName, mimeType, fileSize) {
1795
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/attachment/metadata';
1796
+ const headers = {
1797
+ 'Authorization': `JWT ${contextJwt}`,
1798
+ 'jira-project-id': String(projectId),
1799
+ 'Content-Type': 'application/json',
1800
+ 'Accept': 'application/json',
1801
+ };
1802
+ const createdOn = new Date().toISOString();
1803
+ const payload = {
1804
+ createdOn,
1805
+ mimeType,
1806
+ name: fileName,
1807
+ s3Key,
1808
+ size: fileSize,
1809
+ stepId,
1810
+ userAccountId,
1811
+ };
1812
+ try {
1813
+ const response = await fetch(url, {
1814
+ method: 'POST',
1815
+ headers,
1816
+ body: JSON.stringify(payload),
1817
+ });
1818
+ if (!response.ok) {
1819
+ if (response.status === 400) {
1820
+ const errorText = await response.text().catch(() => 'Bad Request');
1821
+ throw new BadRequestError(`Failed to save attachment metadata: ${errorText}`, response.statusText);
1822
+ }
1823
+ if (response.status === 401) {
1824
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
1825
+ }
1826
+ throw new ServerError(`Failed to save attachment metadata. Status: ${response.status}`, response.status, response.statusText);
1827
+ }
1828
+ return await response.json();
1829
+ }
1830
+ catch (error) {
1831
+ if (error instanceof BadRequestError ||
1832
+ error instanceof UnauthorizedError ||
1833
+ error instanceof ServerError) {
1834
+ throw error;
1835
+ }
1836
+ throw new UnexpectedError('Unexpected error while saving attachment metadata', error);
1837
+ }
1838
+ }
1839
+ /**
1840
+ * Upload file to S3 for test execution
1841
+ *
1842
+ * Uploads a file to S3 using the credentials from upload details.
1843
+ * Uses "testresult" in the S3 key path.
1844
+ *
1845
+ * @param upload - Upload details from getUploadDetails
1846
+ * @param projectId - Jira project ID
1847
+ * @param testExecutionId - Numeric test execution ID
1848
+ * @param userAccountId - Atlassian account ID
1849
+ * @param file - File to upload (Blob or ArrayBuffer)
1850
+ * @param fileName - Name of the file
1851
+ * @returns S3 upload information
1852
+ * @throws {UnexpectedError} If upload fails
1853
+ */
1854
+ async uploadToS3ForTestExecution(upload, projectId, testExecutionId, userAccountId, file, fileName) {
1855
+ const bucketUrl = upload.bucketUrl;
1856
+ const keyPrefix = upload.keyPrefix;
1857
+ const credential = upload.credential;
1858
+ const date = upload.date;
1859
+ const policy = upload.policy;
1860
+ const signature = upload.signature;
1861
+ // Generate UUID for file
1862
+ const fileUuid = this.generateUUID();
1863
+ // Note: Test executions use "testresult" in the S3 key path, and the keyPrefix includes "write/"
1864
+ const s3Key = `${keyPrefix}/project/${projectId}/testresult/${testExecutionId}/${fileUuid}`;
1865
+ // Determine MIME type
1866
+ const mimeType = this.getMimeType(fileName);
1867
+ // Get file size
1868
+ const fileSize = file instanceof Blob ? file.size : file.byteLength;
1869
+ // Create form data
1870
+ const formData = new FormData();
1871
+ formData.append('key', s3Key);
1872
+ formData.append('acl', 'private');
1873
+ formData.append('Policy', policy);
1874
+ formData.append('X-Amz-Credential', credential);
1875
+ formData.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
1876
+ formData.append('X-Amz-Date', date);
1877
+ formData.append('X-Amz-Signature', signature);
1878
+ formData.append('X-Amz-Meta-user-account-id', userAccountId);
1879
+ formData.append('X-Amz-Meta-name', fileName);
1880
+ formData.append('Content-Type', mimeType);
1881
+ // Add file
1882
+ if (file instanceof Blob) {
1883
+ formData.append('file', file, fileName);
1884
+ }
1885
+ else {
1886
+ formData.append('file', new Blob([file], { type: mimeType }), fileName);
1887
+ }
1888
+ try {
1889
+ const response = await fetch(bucketUrl, {
1890
+ method: 'POST',
1891
+ body: formData,
1892
+ });
1893
+ if (!response.ok && response.status !== 200 && response.status !== 201 && response.status !== 204) {
1894
+ const errorText = await response.text().catch(() => 'Upload failed');
1895
+ throw new UnexpectedError(`Failed to upload file to S3: ${errorText}`);
1896
+ }
1897
+ return {
1898
+ s3Key,
1899
+ fileName,
1900
+ mimeType,
1901
+ fileSize,
1902
+ };
1903
+ }
1904
+ catch (error) {
1905
+ if (error instanceof UnexpectedError) {
1906
+ throw error;
1907
+ }
1908
+ throw new UnexpectedError('Unexpected error while uploading file to S3', error);
1909
+ }
1910
+ }
1911
+ /**
1912
+ * Save test execution attachment metadata
1913
+ *
1914
+ * Saves metadata for an uploaded test execution attachment.
1915
+ *
1916
+ * @param contextJwt - Jira Context JWT token
1917
+ * @param projectId - Jira project ID
1918
+ * @param testExecutionId - Numeric test execution ID
1919
+ * @param userAccountId - Atlassian account ID
1920
+ * @param s3Key - S3 key from upload
1921
+ * @param fileName - File name
1922
+ * @param mimeType - MIME type
1923
+ * @param fileSize - File size in bytes
1924
+ * @returns Attachment metadata response
1925
+ * @throws {BadRequestError} If the request is invalid
1926
+ * @throws {UnauthorizedError} If authentication fails
1927
+ * @throws {ServerError} If the server returns an error
1928
+ */
1929
+ async saveTestExecutionAttachmentMetadata(contextJwt, projectId, testExecutionId, userAccountId, s3Key, fileName, mimeType, fileSize) {
1930
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/attachment/metadata';
1931
+ const headers = {
1932
+ 'Authorization': `JWT ${contextJwt}`,
1933
+ 'jira-project-id': String(projectId),
1934
+ 'Content-Type': 'application/json',
1935
+ 'Accept': 'application/json',
1936
+ };
1937
+ const createdOn = new Date().toISOString();
1938
+ const payload = {
1939
+ createdOn,
1940
+ mimeType,
1941
+ name: fileName,
1942
+ s3Key,
1943
+ size: fileSize,
1944
+ testExecutionId,
1945
+ userAccountId,
1946
+ };
1947
+ try {
1948
+ const response = await fetch(url, {
1949
+ method: 'POST',
1950
+ headers,
1951
+ body: JSON.stringify(payload),
1952
+ });
1953
+ if (!response.ok) {
1954
+ if (response.status === 400) {
1955
+ const errorText = await response.text().catch(() => 'Bad Request');
1956
+ throw new BadRequestError(`Failed to save attachment metadata: ${errorText}`, response.statusText);
1957
+ }
1958
+ if (response.status === 401) {
1959
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
1960
+ }
1961
+ throw new ServerError(`Failed to save attachment metadata. Status: ${response.status}`, response.status, response.statusText);
1962
+ }
1963
+ return await response.json();
1964
+ }
1965
+ catch (error) {
1966
+ if (error instanceof BadRequestError ||
1967
+ error instanceof UnauthorizedError ||
1968
+ error instanceof ServerError) {
1969
+ throw error;
1970
+ }
1971
+ throw new UnexpectedError('Unexpected error while saving attachment metadata', error);
1972
+ }
1973
+ }
1974
+ /**
1975
+ * Upload file to S3 for test execution step
1976
+ *
1977
+ * Uploads a file to S3 using the credentials from upload details.
1978
+ * Uses "testscriptresult" in the S3 key path.
1979
+ *
1980
+ * @param upload - Upload details from getUploadDetails
1981
+ * @param projectId - Jira project ID
1982
+ * @param testScriptResultId - Numeric test script result ID
1983
+ * @param userAccountId - Atlassian account ID
1984
+ * @param file - File to upload (Blob or ArrayBuffer)
1985
+ * @param fileName - Name of the file
1986
+ * @returns S3 upload information
1987
+ * @throws {UnexpectedError} If upload fails
1988
+ */
1989
+ async uploadToS3ForTestExecutionStep(upload, projectId, testScriptResultId, userAccountId, file, fileName) {
1990
+ const bucketUrl = upload.bucketUrl;
1991
+ const keyPrefix = upload.keyPrefix;
1992
+ const credential = upload.credential;
1993
+ const date = upload.date;
1994
+ const policy = upload.policy;
1995
+ const signature = upload.signature;
1996
+ // Generate UUID for file
1997
+ const fileUuid = this.generateUUID();
1998
+ // Note: Test execution steps use "testscriptresult" in the S3 key path, and the keyPrefix includes "write/"
1999
+ const s3Key = `${keyPrefix}/project/${projectId}/testscriptresult/${testScriptResultId}/${fileUuid}`;
2000
+ // Determine MIME type
2001
+ const mimeType = this.getMimeType(fileName);
2002
+ // Get file size
2003
+ const fileSize = file instanceof Blob ? file.size : file.byteLength;
2004
+ // Create form data
2005
+ const formData = new FormData();
2006
+ formData.append('key', s3Key);
2007
+ formData.append('acl', 'private');
2008
+ formData.append('Policy', policy);
2009
+ formData.append('X-Amz-Credential', credential);
2010
+ formData.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
2011
+ formData.append('X-Amz-Date', date);
2012
+ formData.append('X-Amz-Signature', signature);
2013
+ formData.append('X-Amz-Meta-user-account-id', userAccountId);
2014
+ formData.append('X-Amz-Meta-name', fileName);
2015
+ formData.append('Content-Type', mimeType);
2016
+ // Add file
2017
+ if (file instanceof Blob) {
2018
+ formData.append('file', file, fileName);
2019
+ }
2020
+ else {
2021
+ formData.append('file', new Blob([file], { type: mimeType }), fileName);
2022
+ }
2023
+ try {
2024
+ const response = await fetch(bucketUrl, {
2025
+ method: 'POST',
2026
+ body: formData,
2027
+ });
2028
+ if (!response.ok && response.status !== 200 && response.status !== 201 && response.status !== 204) {
2029
+ const errorText = await response.text().catch(() => 'Upload failed');
2030
+ throw new UnexpectedError(`Failed to upload file to S3: ${errorText}`);
2031
+ }
2032
+ return {
2033
+ s3Key,
2034
+ fileName,
2035
+ mimeType,
2036
+ fileSize,
2037
+ };
2038
+ }
2039
+ catch (error) {
2040
+ if (error instanceof UnexpectedError) {
2041
+ throw error;
2042
+ }
2043
+ throw new UnexpectedError('Unexpected error while uploading file to S3', error);
2044
+ }
2045
+ }
2046
+ /**
2047
+ * Save test execution step attachment metadata
2048
+ *
2049
+ * Saves metadata for an uploaded test execution step attachment.
2050
+ *
2051
+ * @param contextJwt - Jira Context JWT token
2052
+ * @param projectId - Jira project ID
2053
+ * @param testScriptResultId - Numeric test script result ID
2054
+ * @param userAccountId - Atlassian account ID
2055
+ * @param s3Key - S3 key from upload
2056
+ * @param fileName - File name
2057
+ * @param mimeType - MIME type
2058
+ * @param fileSize - File size in bytes
2059
+ * @returns Attachment metadata response
2060
+ * @throws {BadRequestError} If the request is invalid
2061
+ * @throws {UnauthorizedError} If authentication fails
2062
+ * @throws {ServerError} If the server returns an error
2063
+ */
2064
+ async saveTestExecutionStepAttachmentMetadata(contextJwt, projectId, testScriptResultId, userAccountId, s3Key, fileName, mimeType, fileSize) {
2065
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/attachment/metadata';
2066
+ const headers = {
2067
+ 'Authorization': `JWT ${contextJwt}`,
2068
+ 'jira-project-id': String(projectId),
2069
+ 'Content-Type': 'application/json',
2070
+ 'Accept': 'application/json',
2071
+ };
2072
+ const createdOn = new Date().toISOString();
2073
+ const payload = {
2074
+ createdOn,
2075
+ mimeType,
2076
+ name: fileName,
2077
+ s3Key,
2078
+ size: fileSize,
2079
+ testScriptResultId,
2080
+ userAccountId,
2081
+ };
2082
+ try {
2083
+ const response = await fetch(url, {
2084
+ method: 'POST',
2085
+ headers,
2086
+ body: JSON.stringify(payload),
2087
+ });
2088
+ if (!response.ok) {
2089
+ if (response.status === 400) {
2090
+ const errorText = await response.text().catch(() => 'Bad Request');
2091
+ throw new BadRequestError(`Failed to save attachment metadata: ${errorText}`, response.statusText);
2092
+ }
2093
+ if (response.status === 401) {
2094
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
2095
+ }
2096
+ throw new ServerError(`Failed to save attachment metadata. Status: ${response.status}`, response.status, response.statusText);
2097
+ }
2098
+ return await response.json();
2099
+ }
2100
+ catch (error) {
2101
+ if (error instanceof BadRequestError ||
2102
+ error instanceof UnauthorizedError ||
2103
+ error instanceof ServerError) {
2104
+ throw error;
2105
+ }
2106
+ throw new UnexpectedError('Unexpected error while saving attachment metadata', error);
2107
+ }
2108
+ }
2109
+ }