@rbaileysr/zephyr-managed-api 1.1.0 → 1.2.1

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.
@@ -1,6 +1,7 @@
1
1
  /*!
2
2
  * Copyright Adaptavist 2025 (c) All rights reserved
3
3
  */
4
+ import { TestCaseGroup } from './TestCase';
4
5
  import { BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ServerError, UnexpectedError } from '../utils';
5
6
  /**
6
7
  * Private API group for accessing Zephyr's private/unofficial endpoints
@@ -9,11 +10,15 @@ import { BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, Serv
9
10
  * They may change or break at any time without notice.
10
11
  */
11
12
  export class PrivateGroup {
12
- constructor() {
13
+ constructor(apiConnection) {
13
14
  /**
14
15
  * Base URL for Zephyr private API endpoints
15
16
  */
16
17
  this.privateApiBaseUrl = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0';
18
+ this.apiConnection = apiConnection;
19
+ if (apiConnection) {
20
+ this.testCaseGroup = new TestCaseGroup(apiConnection);
21
+ }
17
22
  }
18
23
  /**
19
24
  * Get Jira Context JWT token
@@ -162,23 +167,45 @@ export class PrivateGroup {
162
167
  * @param userEmail - Jira user email address
163
168
  * @param apiToken - Jira API token
164
169
  * @param jiraInstanceUrl - Full Jira instance URL (e.g., 'https://your-instance.atlassian.net')
165
- * @param testCaseId - Numeric test case ID (not the test case key)
166
- * @param projectId - Jira project ID or key
167
- * @returns The created version response
170
+ * @param request - Test case version creation request
171
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
172
+ * @param request.projectId - Jira project ID (numeric, not the project key)
173
+ * @returns The created version response with id and key
168
174
  * @throws {BadRequestError} If the request is invalid
169
175
  * @throws {UnauthorizedError} If authentication fails
170
176
  * @throws {ForbiddenError} If the user doesn't have permission
171
177
  * @throws {NotFoundError} If the test case is not found
172
178
  * @throws {ServerError} If the server returns an error (including 409 Conflict if version already exists)
179
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available
173
180
  */
174
- async createTestCaseVersion(userEmail, apiToken, jiraInstanceUrl, testCaseId, projectId) {
181
+ async createTestCaseVersion(userEmail, apiToken, jiraInstanceUrl, request) {
182
+ // Get test case ID from key if we have API connection
183
+ let testCaseId;
184
+ if (this.testCaseGroup) {
185
+ try {
186
+ const testCase = await this.testCaseGroup.getTestCase({ testCaseKey: request.testCaseKey });
187
+ if (!testCase) {
188
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
189
+ }
190
+ testCaseId = testCase.id;
191
+ }
192
+ catch (error) {
193
+ if (error instanceof NotFoundError) {
194
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
195
+ }
196
+ throw new UnexpectedError(`Failed to look up test case ID from key '${request.testCaseKey}'. Ensure Zephyr Connector is configured.`, error);
197
+ }
198
+ }
199
+ else {
200
+ throw new UnexpectedError('Cannot look up test case ID from key. This method requires Zephyr Connector to be configured. Use createZephyrApi() with an API Connection.');
201
+ }
175
202
  // Get Context JWT
176
203
  const contextJwt = await this.getContextJwt(userEmail, apiToken, jiraInstanceUrl);
177
204
  const url = `${this.privateApiBaseUrl}/testcase/${testCaseId}/newversion`;
178
205
  const headers = {
179
206
  'Content-Type': 'application/json',
180
207
  'authorization': `JWT ${contextJwt}`,
181
- 'jira-project-id': String(projectId),
208
+ 'jira-project-id': String(request.projectId),
182
209
  };
183
210
  try {
184
211
  const response = await fetch(url, {
@@ -212,10 +239,530 @@ export class PrivateGroup {
212
239
  error instanceof UnauthorizedError ||
213
240
  error instanceof ForbiddenError ||
214
241
  error instanceof NotFoundError ||
215
- error instanceof ServerError) {
242
+ error instanceof ServerError ||
243
+ error instanceof UnexpectedError) {
216
244
  throw error;
217
245
  }
218
246
  throw new UnexpectedError('Unexpected error while creating test case version', error);
219
247
  }
220
248
  }
249
+ /**
250
+ * Get upload details for attachment upload
251
+ *
252
+ * Retrieves S3 upload credentials and configuration needed to upload an attachment.
253
+ *
254
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
255
+ * The endpoint may change or be removed at any time without notice.
256
+ *
257
+ * @param contextJwt - Jira Context JWT token
258
+ * @returns Upload details including S3 bucket URL, credentials, and policy
259
+ * @throws {UnauthorizedError} If authentication fails
260
+ * @throws {ServerError} If the server returns an error
261
+ */
262
+ async getUploadDetails(contextJwt) {
263
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/uploaddetails/attachment';
264
+ const headers = {
265
+ 'Authorization': `JWT ${contextJwt}`,
266
+ 'Accept': 'application/json',
267
+ };
268
+ try {
269
+ const response = await fetch(url, {
270
+ method: 'GET',
271
+ headers,
272
+ });
273
+ if (!response.ok) {
274
+ if (response.status === 401) {
275
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
276
+ }
277
+ throw new ServerError(`Failed to get upload details. Status: ${response.status}`, response.status, response.statusText);
278
+ }
279
+ return await response.json();
280
+ }
281
+ catch (error) {
282
+ if (error instanceof UnauthorizedError || error instanceof ServerError) {
283
+ throw error;
284
+ }
285
+ throw new UnexpectedError('Unexpected error while getting upload details', error);
286
+ }
287
+ }
288
+ /**
289
+ * Upload file to S3
290
+ *
291
+ * Uploads a file to S3 using the credentials from upload details.
292
+ *
293
+ * @param upload - Upload details from getUploadDetails
294
+ * @param projectId - Jira project ID
295
+ * @param testCaseId - Numeric test case ID
296
+ * @param userAccountId - Atlassian account ID
297
+ * @param file - File to upload (Blob or ArrayBuffer)
298
+ * @param fileName - Name of the file
299
+ * @returns S3 upload information
300
+ * @throws {UnexpectedError} If upload fails
301
+ */
302
+ async uploadToS3(upload, projectId, testCaseId, userAccountId, file, fileName) {
303
+ const bucketUrl = upload.bucketUrl;
304
+ const keyPrefix = upload.keyPrefix;
305
+ const credential = upload.credential;
306
+ const date = upload.date;
307
+ const policy = upload.policy;
308
+ const signature = upload.signature;
309
+ // Generate UUID for file
310
+ const fileUuid = this.generateUUID();
311
+ const s3Key = `${keyPrefix}/project/${projectId}/testcase/${testCaseId}/${fileUuid}`;
312
+ // Determine MIME type
313
+ const mimeType = this.getMimeType(fileName);
314
+ // Get file size
315
+ const fileSize = file instanceof Blob ? file.size : file.byteLength;
316
+ // Create form data
317
+ const formData = new FormData();
318
+ formData.append('key', s3Key);
319
+ formData.append('acl', 'private');
320
+ formData.append('Policy', policy);
321
+ formData.append('X-Amz-Credential', credential);
322
+ formData.append('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
323
+ formData.append('X-Amz-Date', date);
324
+ formData.append('X-Amz-Signature', signature);
325
+ formData.append('X-Amz-Meta-user-account-id', userAccountId);
326
+ formData.append('X-Amz-Meta-name', fileName);
327
+ formData.append('Content-Type', mimeType);
328
+ // Add file
329
+ if (file instanceof Blob) {
330
+ formData.append('file', file, fileName);
331
+ }
332
+ else {
333
+ formData.append('file', new Blob([file], { type: mimeType }), fileName);
334
+ }
335
+ try {
336
+ const response = await fetch(bucketUrl, {
337
+ method: 'POST',
338
+ body: formData,
339
+ });
340
+ if (!response.ok && response.status !== 200 && response.status !== 201 && response.status !== 204) {
341
+ const errorText = await response.text().catch(() => 'Upload failed');
342
+ throw new UnexpectedError(`Failed to upload file to S3: ${errorText}`);
343
+ }
344
+ return {
345
+ s3Key,
346
+ fileName,
347
+ mimeType,
348
+ fileSize,
349
+ };
350
+ }
351
+ catch (error) {
352
+ if (error instanceof UnexpectedError) {
353
+ throw error;
354
+ }
355
+ throw new UnexpectedError('Unexpected error while uploading file to S3', error);
356
+ }
357
+ }
358
+ /**
359
+ * Save attachment metadata
360
+ *
361
+ * Saves metadata for an uploaded attachment.
362
+ *
363
+ * @param contextJwt - Jira Context JWT token
364
+ * @param projectId - Jira project ID
365
+ * @param testCaseId - Numeric test case ID
366
+ * @param userAccountId - Atlassian account ID
367
+ * @param s3Key - S3 key from upload
368
+ * @param fileName - File name
369
+ * @param mimeType - MIME type
370
+ * @param fileSize - File size in bytes
371
+ * @returns Attachment metadata response
372
+ * @throws {BadRequestError} If the request is invalid
373
+ * @throws {UnauthorizedError} If authentication fails
374
+ * @throws {ServerError} If the server returns an error
375
+ */
376
+ async saveAttachmentMetadata(contextJwt, projectId, testCaseId, userAccountId, s3Key, fileName, mimeType, fileSize) {
377
+ const url = 'https://app.tm4j.smartbear.com/backend/rest/tests/2.0/attachment/metadata';
378
+ const headers = {
379
+ 'Authorization': `JWT ${contextJwt}`,
380
+ 'jira-project-id': String(projectId),
381
+ 'Content-Type': 'application/json',
382
+ 'Accept': 'application/json',
383
+ };
384
+ const createdOn = new Date().toISOString();
385
+ const payload = {
386
+ createdOn,
387
+ mimeType,
388
+ name: fileName,
389
+ s3Key,
390
+ size: fileSize,
391
+ testCaseId,
392
+ userAccountId,
393
+ };
394
+ try {
395
+ const response = await fetch(url, {
396
+ method: 'POST',
397
+ headers,
398
+ body: JSON.stringify(payload),
399
+ });
400
+ if (!response.ok) {
401
+ if (response.status === 400) {
402
+ const errorText = await response.text().catch(() => 'Bad Request');
403
+ throw new BadRequestError(`Failed to save attachment metadata: ${errorText}`, response.statusText);
404
+ }
405
+ if (response.status === 401) {
406
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
407
+ }
408
+ throw new ServerError(`Failed to save attachment metadata. Status: ${response.status}`, response.status, response.statusText);
409
+ }
410
+ return await response.json();
411
+ }
412
+ catch (error) {
413
+ if (error instanceof BadRequestError ||
414
+ error instanceof UnauthorizedError ||
415
+ error instanceof ServerError) {
416
+ throw error;
417
+ }
418
+ throw new UnexpectedError('Unexpected error while saving attachment metadata', error);
419
+ }
420
+ }
421
+ /**
422
+ * Create a comment on a test case using private API
423
+ *
424
+ * Adds a comment to an existing test case. The comment will be associated with
425
+ * the specified user account ID.
426
+ *
427
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
428
+ * The endpoint may change or be removed at any time without notice.
429
+ *
430
+ * @param userEmail - Jira user email address
431
+ * @param apiToken - Jira API token
432
+ * @param jiraInstanceUrl - Full Jira instance URL (e.g., 'https://your-instance.atlassian.net')
433
+ * @param request - Comment creation request
434
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
435
+ * @param request.projectId - Jira project ID (numeric, not the project key)
436
+ * @param request.body - Comment text/body
437
+ * @param request.createdBy - Atlassian account ID of the user creating the comment (e.g., '5d6fdc98dc6e480dbc021aae')
438
+ * @returns Comment creation response
439
+ * @throws {BadRequestError} If the request is invalid
440
+ * @throws {UnauthorizedError} If authentication fails
441
+ * @throws {ForbiddenError} If the user doesn't have permission
442
+ * @throws {NotFoundError} If the test case is not found
443
+ * @throws {ServerError} If the server returns an error
444
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available
445
+ */
446
+ async createTestCaseComment(userEmail, apiToken, jiraInstanceUrl, request) {
447
+ // Get test case ID from key if we have API connection
448
+ let testCaseId;
449
+ if (this.testCaseGroup) {
450
+ try {
451
+ const testCase = await this.testCaseGroup.getTestCase({ testCaseKey: request.testCaseKey });
452
+ if (!testCase) {
453
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
454
+ }
455
+ testCaseId = testCase.id;
456
+ }
457
+ catch (error) {
458
+ if (error instanceof NotFoundError) {
459
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
460
+ }
461
+ throw new UnexpectedError(`Failed to look up test case ID from key '${request.testCaseKey}'. Ensure Zephyr Connector is configured.`, error);
462
+ }
463
+ }
464
+ else {
465
+ 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.');
466
+ }
467
+ // Get Context JWT
468
+ const contextJwt = await this.getContextJwt(userEmail, apiToken, jiraInstanceUrl);
469
+ const url = `${this.privateApiBaseUrl}/testcase/${testCaseId}/comments`;
470
+ const headers = {
471
+ 'Content-Type': 'application/json',
472
+ 'authorization': `JWT ${contextJwt}`,
473
+ 'jira-project-id': String(request.projectId),
474
+ };
475
+ const payload = {
476
+ body: request.body,
477
+ createdBy: request.createdBy,
478
+ };
479
+ try {
480
+ const response = await fetch(url, {
481
+ method: 'POST',
482
+ headers,
483
+ body: JSON.stringify(payload),
484
+ });
485
+ if (!response.ok) {
486
+ if (response.status === 400) {
487
+ const errorText = await response.text().catch(() => 'Bad Request');
488
+ throw new BadRequestError(`Failed to create test case comment: ${errorText}`, response.statusText);
489
+ }
490
+ if (response.status === 401) {
491
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
492
+ }
493
+ if (response.status === 403) {
494
+ throw new ForbiddenError('You do not have permission to create comments on this test case.');
495
+ }
496
+ if (response.status === 404) {
497
+ throw new NotFoundError('Test case not found.');
498
+ }
499
+ throw new ServerError(`Failed to create test case comment. Status: ${response.status}`, response.status, response.statusText);
500
+ }
501
+ return await response.json();
502
+ }
503
+ catch (error) {
504
+ if (error instanceof BadRequestError ||
505
+ error instanceof UnauthorizedError ||
506
+ error instanceof ForbiddenError ||
507
+ error instanceof NotFoundError ||
508
+ error instanceof ServerError ||
509
+ error instanceof UnexpectedError) {
510
+ throw error;
511
+ }
512
+ throw new UnexpectedError('Unexpected error while creating test case comment', error);
513
+ }
514
+ }
515
+ /**
516
+ * Get attachments for a test case using private API
517
+ *
518
+ * Retrieves all attachment details for a test case, including signed S3 URLs
519
+ * for downloading the attachments.
520
+ *
521
+ * ⚠️ WARNING: This uses a private Zephyr API endpoint that is not officially supported.
522
+ * The endpoint may change or be removed at any time without notice.
523
+ *
524
+ * @param userEmail - Jira user email address
525
+ * @param apiToken - Jira API token
526
+ * @param jiraInstanceUrl - Full Jira instance URL (e.g., 'https://your-instance.atlassian.net')
527
+ * @param request - Get attachments request
528
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
529
+ * @param request.projectId - Jira project ID (numeric, not the project key)
530
+ * @returns Test case attachments response with array of attachment details
531
+ * @throws {BadRequestError} If the request is invalid
532
+ * @throws {UnauthorizedError} If authentication fails
533
+ * @throws {ForbiddenError} If the user doesn't have permission
534
+ * @throws {NotFoundError} If the test case is not found
535
+ * @throws {ServerError} If the server returns an error
536
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available
537
+ */
538
+ async getTestCaseAttachments(userEmail, apiToken, jiraInstanceUrl, request) {
539
+ // Get test case ID from key if we have API connection
540
+ let testCaseId;
541
+ if (this.testCaseGroup) {
542
+ try {
543
+ const testCase = await this.testCaseGroup.getTestCase({ testCaseKey: request.testCaseKey });
544
+ if (!testCase) {
545
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
546
+ }
547
+ testCaseId = testCase.id;
548
+ }
549
+ catch (error) {
550
+ if (error instanceof NotFoundError) {
551
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
552
+ }
553
+ throw new UnexpectedError(`Failed to look up test case ID from key '${request.testCaseKey}'. Ensure Zephyr Connector is configured.`, error);
554
+ }
555
+ }
556
+ else {
557
+ 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.');
558
+ }
559
+ // Get Context JWT
560
+ const contextJwt = await this.getContextJwt(userEmail, apiToken, jiraInstanceUrl);
561
+ const url = `${this.privateApiBaseUrl}/testcase/${testCaseId}?fields=attachments`;
562
+ const headers = {
563
+ 'accept': 'application/json',
564
+ 'authorization': `JWT ${contextJwt}`,
565
+ 'jira-project-id': String(request.projectId),
566
+ };
567
+ try {
568
+ const response = await fetch(url, {
569
+ method: 'GET',
570
+ headers,
571
+ });
572
+ if (!response.ok) {
573
+ if (response.status === 400) {
574
+ const errorText = await response.text().catch(() => 'Bad Request');
575
+ throw new BadRequestError(`Failed to get test case attachments: ${errorText}`, response.statusText);
576
+ }
577
+ if (response.status === 401) {
578
+ throw new UnauthorizedError('Authentication failed. Please check your credentials.');
579
+ }
580
+ if (response.status === 403) {
581
+ throw new ForbiddenError('You do not have permission to view attachments for this test case.');
582
+ }
583
+ if (response.status === 404) {
584
+ throw new NotFoundError('Test case not found.');
585
+ }
586
+ throw new ServerError(`Failed to get test case attachments. Status: ${response.status}`, response.status, response.statusText);
587
+ }
588
+ return await response.json();
589
+ }
590
+ catch (error) {
591
+ if (error instanceof BadRequestError ||
592
+ error instanceof UnauthorizedError ||
593
+ error instanceof ForbiddenError ||
594
+ error instanceof NotFoundError ||
595
+ error instanceof ServerError ||
596
+ error instanceof UnexpectedError) {
597
+ throw error;
598
+ }
599
+ throw new UnexpectedError('Unexpected error while getting test case attachments', error);
600
+ }
601
+ }
602
+ /**
603
+ * Download an attachment from a test case using private API
604
+ *
605
+ * Downloads an attachment file into memory. This method:
606
+ * 1. Gets fresh attachment details (including a fresh signed S3 URL)
607
+ * 2. Downloads the file from the signed URL
608
+ * 3. Returns the file as a Blob
609
+ *
610
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
611
+ * The endpoints may change or be removed at any time without notice.
612
+ *
613
+ * @param userEmail - Jira user email address
614
+ * @param apiToken - Jira API token
615
+ * @param jiraInstanceUrl - Full Jira instance URL (e.g., 'https://your-instance.atlassian.net')
616
+ * @param request - Download attachment request
617
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
618
+ * @param request.projectId - Jira project ID (numeric, not the project key)
619
+ * @param request.attachmentId - Attachment ID (UUID string, e.g., 'c3f14125-638f-47f9-9211-12a9777c09e7')
620
+ * @returns Blob containing the attachment file data
621
+ * @throws {BadRequestError} If the request is invalid
622
+ * @throws {UnauthorizedError} If authentication fails
623
+ * @throws {ForbiddenError} If the user doesn't have permission
624
+ * @throws {NotFoundError} If the test case or attachment is not found
625
+ * @throws {ServerError} If the server returns an error
626
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available, or if download fails
627
+ */
628
+ async downloadAttachment(userEmail, apiToken, jiraInstanceUrl, request) {
629
+ // Step 1: Get fresh attachment details (including fresh signed URL)
630
+ const attachmentsResponse = await this.getTestCaseAttachments(userEmail, apiToken, jiraInstanceUrl, {
631
+ testCaseKey: request.testCaseKey,
632
+ projectId: request.projectId,
633
+ });
634
+ // Step 2: Find the requested attachment
635
+ const attachment = attachmentsResponse.attachments.find((att) => att.id === request.attachmentId);
636
+ if (!attachment) {
637
+ throw new NotFoundError(`Attachment with ID '${request.attachmentId}' not found for test case '${request.testCaseKey}'.`);
638
+ }
639
+ // Step 3: Download the file from the signed S3 URL
640
+ try {
641
+ const downloadResponse = await fetch(attachment.url, {
642
+ method: 'GET',
643
+ });
644
+ if (!downloadResponse.ok) {
645
+ if (downloadResponse.status === 403) {
646
+ throw new ForbiddenError('Failed to download attachment. The signed URL may have expired. Please try again.');
647
+ }
648
+ if (downloadResponse.status === 404) {
649
+ throw new NotFoundError('Attachment file not found on S3.');
650
+ }
651
+ throw new ServerError(`Failed to download attachment. Status: ${downloadResponse.status}`, downloadResponse.status, downloadResponse.statusText);
652
+ }
653
+ // Return as Blob
654
+ return await downloadResponse.blob();
655
+ }
656
+ catch (error) {
657
+ if (error instanceof ForbiddenError ||
658
+ error instanceof NotFoundError ||
659
+ error instanceof ServerError) {
660
+ throw error;
661
+ }
662
+ throw new UnexpectedError('Unexpected error while downloading attachment', error);
663
+ }
664
+ }
665
+ /**
666
+ * Generate a UUID v4 (compatible with ScriptRunner Connect runtime)
667
+ *
668
+ * Uses crypto.getRandomValues() which is available in web standards
669
+ */
670
+ generateUUID() {
671
+ // Use crypto.getRandomValues() which is available in ScriptRunner Connect
672
+ const bytes = new Uint8Array(16);
673
+ crypto.getRandomValues(bytes);
674
+ // Set version (4) and variant bits
675
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
676
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
677
+ // Convert to UUID string format
678
+ const hex = [];
679
+ for (let i = 0; i < bytes.length; i++) {
680
+ hex.push(bytes[i].toString(16).padStart(2, '0'));
681
+ }
682
+ return [
683
+ hex.slice(0, 4).join(''),
684
+ hex.slice(4, 6).join(''),
685
+ hex.slice(6, 8).join(''),
686
+ hex.slice(8, 10).join(''),
687
+ hex.slice(10, 16).join(''),
688
+ ].join('-');
689
+ }
690
+ /**
691
+ * Get MIME type from file name
692
+ */
693
+ getMimeType(fileName) {
694
+ // Simple MIME type detection based on extension
695
+ const extension = fileName.split('.').pop()?.toLowerCase();
696
+ const mimeTypes = {
697
+ png: 'image/png',
698
+ jpg: 'image/jpeg',
699
+ jpeg: 'image/jpeg',
700
+ gif: 'image/gif',
701
+ pdf: 'application/pdf',
702
+ txt: 'text/plain',
703
+ csv: 'text/csv',
704
+ json: 'application/json',
705
+ xml: 'application/xml',
706
+ zip: 'application/zip',
707
+ };
708
+ return mimeTypes[extension || ''] || 'application/octet-stream';
709
+ }
710
+ /**
711
+ * Create an attachment for a test case using private API
712
+ *
713
+ * Uploads a file attachment to a test case. This involves:
714
+ * 1. Getting upload details (S3 credentials)
715
+ * 2. Uploading the file to S3
716
+ * 3. Saving attachment metadata
717
+ *
718
+ * ⚠️ WARNING: This uses private Zephyr API endpoints that are not officially supported.
719
+ * The endpoints may change or be removed at any time without notice.
720
+ *
721
+ * @param userEmail - Jira user email address
722
+ * @param apiToken - Jira API token
723
+ * @param jiraInstanceUrl - Full Jira instance URL (e.g., 'https://your-instance.atlassian.net')
724
+ * @param request - Attachment creation request
725
+ * @param request.testCaseKey - Test case key (e.g., 'PROJ-T1'). The numeric ID will be looked up automatically if Zephyr Connector is available.
726
+ * @param request.projectId - Jira project ID (numeric, not the project key)
727
+ * @param request.file - File to upload (Blob or ArrayBuffer)
728
+ * @param request.fileName - Name of the file
729
+ * @param request.userAccountId - Atlassian account ID (e.g., '5d6fdc98dc6e480dbc021aae')
730
+ * @returns Attachment metadata response
731
+ * @throws {BadRequestError} If the request is invalid
732
+ * @throws {UnauthorizedError} If authentication fails
733
+ * @throws {ForbiddenError} If the user doesn't have permission
734
+ * @throws {NotFoundError} If the test case is not found
735
+ * @throws {ServerError} If the server returns an error
736
+ * @throws {UnexpectedError} If test case ID cannot be looked up from key and Zephyr Connector is not available
737
+ */
738
+ async createAttachment(userEmail, apiToken, jiraInstanceUrl, request) {
739
+ // Get test case ID from key if we have API connection
740
+ let testCaseId;
741
+ if (this.testCaseGroup) {
742
+ try {
743
+ const testCase = await this.testCaseGroup.getTestCase({ testCaseKey: request.testCaseKey });
744
+ if (!testCase) {
745
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
746
+ }
747
+ testCaseId = testCase.id;
748
+ }
749
+ catch (error) {
750
+ if (error instanceof NotFoundError) {
751
+ throw new NotFoundError(`Test case with key '${request.testCaseKey}' not found.`);
752
+ }
753
+ throw new UnexpectedError(`Failed to look up test case ID from key '${request.testCaseKey}'. Ensure Zephyr Connector is configured.`, error);
754
+ }
755
+ }
756
+ else {
757
+ 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.');
758
+ }
759
+ // Get Context JWT
760
+ const contextJwt = await this.getContextJwt(userEmail, apiToken, jiraInstanceUrl);
761
+ // Step 1: Get upload details
762
+ const uploadDetails = await this.getUploadDetails(contextJwt);
763
+ // Step 2: Upload to S3
764
+ const s3Info = await this.uploadToS3(uploadDetails, request.projectId, testCaseId, request.userAccountId, request.file, request.fileName);
765
+ // Step 3: Save metadata
766
+ return await this.saveAttachmentMetadata(contextJwt, request.projectId, testCaseId, request.userAccountId, s3Info.s3Key, s3Info.fileName, s3Info.mimeType, s3Info.fileSize);
767
+ }
221
768
  }
package/dist/index.js CHANGED
@@ -98,8 +98,8 @@ export class ZephyrApi {
98
98
  this.Link = new LinkGroup(apiConnection);
99
99
  this.IssueLink = new IssueLinkGroup(apiConnection);
100
100
  this.Automation = new AutomationGroup(apiConnection);
101
- // Private group doesn't need API connection - uses user credentials directly
102
- this.Private = new PrivateGroup();
101
+ // Private group can use API connection to look up test case IDs from keys
102
+ this.Private = new PrivateGroup(apiConnection);
103
103
  this.All = new AllGroup(apiConnection);
104
104
  }
105
105
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rbaileysr/zephyr-managed-api",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Managed API wrapper for Zephyr Cloud REST API v2 - Comprehensive type-safe access to all Zephyr API endpoints for ScriptRunner Connect",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -41,14 +41,6 @@
41
41
  "files": [
42
42
  "dist/",
43
43
  "README.md"
44
- ],
45
- "repository": {
46
- "type": "git",
47
- "url": "https://github.com/rbaileysr/zephyr-managed-api.git"
48
- },
49
- "bugs": {
50
- "url": "https://github.com/rbaileysr/zephyr-managed-api/issues"
51
- },
52
- "homepage": "https://github.com/rbaileysr/zephyr-managed-api#readme"
44
+ ]
53
45
  }
54
46