@rbaileysr/zephyr-managed-api 1.2.1 → 1.2.9

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