Skip to content

Commit afe436d

Browse files
committed
feat: enhance attachment upload functionality in Qase Go SDK
- Updated the `UploadAttachment` method in the `AttachmentUploader` interface to return a slice of hashes, allowing for multiple file uploads. - Modified the `V1Client` to support returning multiple attachment hashes from the API. - Implemented batch processing for attachment uploads in the `V2Converter`, respecting API limits for file size and number of files. - Added unit tests for batch upload functionality to ensure reliability and correctness. - Improved error handling and logging for attachment processing. These changes significantly enhance the attachment upload capabilities, enabling users to upload multiple files efficiently while providing better feedback on the upload process.
1 parent 0d17d81 commit afe436d

File tree

7 files changed

+357
-66
lines changed

7 files changed

+357
-66
lines changed

pkg/qase-go/clients/converter.go

Lines changed: 231 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,24 @@ import (
1414
)
1515

1616
// AttachmentUploader interface for uploading attachments
17+
// Returns a slice of hashes corresponding to the uploaded files
1718
type AttachmentUploader interface {
18-
UploadAttachment(ctx context.Context, projectCode string, file []*os.File) (string, error)
19+
UploadAttachment(ctx context.Context, projectCode string, file []*os.File) ([]string, error)
20+
}
21+
22+
// v1ClientAdapter adapts V1Client to AttachmentUploader interface
23+
type v1ClientAdapter struct {
24+
client *V1Client
25+
}
26+
27+
// UploadAttachment implements AttachmentUploader interface
28+
func (a *v1ClientAdapter) UploadAttachment(ctx context.Context, projectCode string, file []*os.File) ([]string, error) {
29+
return a.client.uploadAttachmentsInternal(ctx, projectCode, file)
30+
}
31+
32+
// NewV1ClientAdapter creates an adapter for V1Client to implement AttachmentUploader
33+
func NewV1ClientAdapter(client *V1Client) AttachmentUploader {
34+
return &v1ClientAdapter{client: client}
1935
}
2036

2137
// V2Converter handles conversion from domain models to API v2 models
@@ -368,43 +384,66 @@ func (c *V2Converter) setAttachments(ctx context.Context, apiResult *api_v2_clie
368384
return nil // Don't fail the entire result due to attachment issues
369385
}
370386

387+
// attachmentToUpload represents an attachment that needs to be uploaded
388+
type attachmentToUpload struct {
389+
attachment domain.Attachment
390+
file *os.File
391+
isTempFile bool // true if this is a temporary file that needs cleanup
392+
}
393+
371394
// processAttachmentsGracefully processes attachments, skipping problematic ones instead of failing
395+
// Groups attachments into batches respecting API limits:
396+
// - Up to 32 MB per file
397+
// - Up to 128 MB per single request
398+
// - Up to 20 files per single request
372399
func (c *V2Converter) processAttachmentsGracefully(ctx context.Context, attachments []domain.Attachment) ([]string, error) {
373400
logging.Debug("Processing %d attachments gracefully", len(attachments))
374401
logging.Debug("Uploader available: %v, project code: %s", c.uploader != nil, c.projectCode)
375402

376403
var attachmentIDs []string
377404
var errors []string
378405

379-
for _, attachment := range attachments {
380-
var attachmentID string
406+
// Separate attachments into those that need upload and those with existing IDs
381407

382-
logging.Debug("Processing attachment: %s, has file path: %v", attachment.String(), attachment.HasFilePath())
408+
var toUpload []attachmentToUpload
409+
var existingIDs []string
383410

384-
// If attachment has a file path and uploader is available, upload the file
385-
if attachment.HasFilePath() && c.uploader != nil && c.projectCode != "" {
386-
logging.Debug("Uploading attachment file: %s", attachment.GetFilePath())
411+
for _, attachment := range attachments {
412+
// If attachment has existing ID and no file path/content, use it directly
413+
if attachment.ID != "" && !attachment.HasFilePath() && len(attachment.Content) == 0 {
414+
existingIDs = append(existingIDs, attachment.ID)
415+
logging.Debug("Using existing attachment ID: %s (no upload)", attachment.ID)
416+
continue
417+
}
387418

388-
file, err := os.Open(attachment.GetFilePath())
389-
if err != nil {
390-
logging.Warn("Warning: Failed to open attachment file %s: %v, skipping", attachment.GetFilePath(), err)
391-
errors = append(errors, fmt.Sprintf("file open failed for %s: %v", attachment.GetFilePath(), err))
392-
continue // Skip this attachment and continue with others
419+
// If no uploader available, try to use existing ID or skip
420+
if c.uploader == nil || c.projectCode == "" {
421+
if attachment.ID != "" {
422+
existingIDs = append(existingIDs, attachment.ID)
423+
logging.Debug("Using existing attachment ID: %s (no uploader available)", attachment.ID)
424+
} else {
425+
logging.Warn("Warning: Attachment '%s' has no ID and no uploader available, skipping", attachment.FileName)
426+
errors = append(errors, fmt.Sprintf("no ID and no uploader for %s", attachment.FileName))
393427
}
428+
continue
429+
}
394430

395-
uploadedHash, err := c.uploader.UploadAttachment(ctx, c.projectCode, []*os.File{file})
396-
file.Close() // Always close the file
431+
// Prepare file for upload
432+
var file *os.File
433+
var isTempFile bool
434+
var err error
397435

436+
if attachment.HasFilePath() {
437+
// Open file from path
438+
file, err = os.Open(attachment.GetFilePath())
398439
if err != nil {
399-
logging.Warn("Warning: Failed to upload attachment %s: %v, skipping", attachment.GetFilePath(), err)
400-
errors = append(errors, fmt.Sprintf("upload failed for %s: %v", attachment.GetFilePath(), err))
401-
continue // Skip this attachment and continue with others
440+
logging.Warn("Warning: Failed to open attachment file %s: %v, skipping", attachment.GetFilePath(), err)
441+
errors = append(errors, fmt.Sprintf("file open failed for %s: %v", attachment.GetFilePath(), err))
442+
continue
402443
}
403-
404-
attachmentID = uploadedHash
405-
logging.Debug("Successfully uploaded attachment, got hash: %s", uploadedHash)
406-
} else if len(attachment.Content) > 0 && c.uploader != nil && c.projectCode != "" {
407-
// Handle content attachments - create temporary file
444+
isTempFile = false
445+
} else if len(attachment.Content) > 0 {
446+
// Create temporary file for content
408447
logging.Debug("Creating temporary file for content attachment: %s", attachment.FileName)
409448

410449
// Extract file extension to preserve it
@@ -416,7 +455,7 @@ func (c *V2Converter) processAttachmentsGracefully(ctx context.Context, attachme
416455
if err != nil {
417456
logging.Warn("Warning: Failed to create temporary file for content attachment %s: %v, skipping", attachment.FileName, err)
418457
errors = append(errors, fmt.Sprintf("temp file creation failed for %s: %v", attachment.FileName, err))
419-
continue // Skip this attachment and continue with others
458+
continue
420459
}
421460

422461
// Write content to temporary file
@@ -425,7 +464,7 @@ func (c *V2Converter) processAttachmentsGracefully(ctx context.Context, attachme
425464
os.Remove(tmpFile.Name()) // Clean up
426465
tmpFile.Close()
427466
errors = append(errors, fmt.Sprintf("content write failed for %s: %v", attachment.FileName, err))
428-
continue // Skip this attachment and continue with others
467+
continue
429468
}
430469

431470
// Reset file pointer to beginning
@@ -434,36 +473,61 @@ func (c *V2Converter) processAttachmentsGracefully(ctx context.Context, attachme
434473
os.Remove(tmpFile.Name()) // Clean up
435474
tmpFile.Close()
436475
errors = append(errors, fmt.Sprintf("file seek failed for %s: %v", attachment.FileName, err))
437-
continue // Skip this attachment and continue with others
438-
}
439-
440-
// Upload temporary file
441-
uploadedHash, err := c.uploader.UploadAttachment(ctx, c.projectCode, []*os.File{tmpFile})
442-
os.Remove(tmpFile.Name()) // Clean up
443-
tmpFile.Close()
444-
445-
if err != nil {
446-
logging.Warn("Warning: Failed to upload content attachment %s: %v, skipping", attachment.FileName, err)
447-
errors = append(errors, fmt.Sprintf("content upload failed for %s: %v", attachment.FileName, err))
448-
continue // Skip this attachment and continue with others
476+
continue
449477
}
450478

451-
attachmentID = uploadedHash
452-
logging.Debug("Successfully uploaded content attachment, got hash: %s", uploadedHash)
479+
file = tmpFile
480+
isTempFile = true
453481
} else {
454-
// Use existing ID if no file path or uploader not available
482+
// No file path, no content, but has ID - use it
455483
if attachment.ID != "" {
456-
attachmentID = attachment.ID
457-
logging.Debug("Using existing attachment ID: %s (no upload)", attachmentID)
484+
existingIDs = append(existingIDs, attachment.ID)
485+
logging.Debug("Using existing attachment ID: %s", attachment.ID)
458486
} else {
459-
// No ID available and no uploader - skip this attachment
460-
logging.Warn("Warning: Attachment '%s' has no ID and no uploader available, skipping", attachment.FileName)
461-
errors = append(errors, fmt.Sprintf("no ID and no uploader for %s", attachment.FileName))
462-
continue
487+
logging.Warn("Warning: Attachment '%s' has no file path, content, or ID, skipping", attachment.FileName)
488+
errors = append(errors, fmt.Sprintf("no file path, content, or ID for %s", attachment.FileName))
489+
}
490+
continue
491+
}
492+
493+
// Check file size (32 MB limit per file)
494+
fileInfo, err := file.Stat()
495+
if err != nil {
496+
logging.Warn("Warning: Failed to get file info for %s: %v, skipping", attachment.FileName, err)
497+
if isTempFile {
498+
os.Remove(file.Name())
499+
}
500+
file.Close()
501+
errors = append(errors, fmt.Sprintf("file stat failed for %s: %v", attachment.FileName, err))
502+
continue
503+
}
504+
505+
const maxFileSize = 32 * 1024 * 1024 // 32 MB
506+
if fileInfo.Size() > maxFileSize {
507+
logging.Warn("Warning: File %s exceeds 32 MB limit (%d bytes), skipping", attachment.FileName, fileInfo.Size())
508+
if isTempFile {
509+
os.Remove(file.Name())
463510
}
511+
file.Close()
512+
errors = append(errors, fmt.Sprintf("file %s exceeds 32 MB limit", attachment.FileName))
513+
continue
464514
}
465515

466-
attachmentIDs = append(attachmentIDs, attachmentID)
516+
toUpload = append(toUpload, attachmentToUpload{
517+
attachment: attachment,
518+
file: file,
519+
isTempFile: isTempFile,
520+
})
521+
}
522+
523+
// Add existing IDs to result
524+
attachmentIDs = append(attachmentIDs, existingIDs...)
525+
526+
// Upload files in batches
527+
if len(toUpload) > 0 {
528+
uploadedIDs, uploadErrors := c.uploadAttachmentsInBatches(ctx, toUpload)
529+
attachmentIDs = append(attachmentIDs, uploadedIDs...)
530+
errors = append(errors, uploadErrors...)
467531
}
468532

469533
// Log summary
@@ -481,6 +545,127 @@ func (c *V2Converter) processAttachmentsGracefully(ctx context.Context, attachme
481545
return attachmentIDs, nil
482546
}
483547

548+
// uploadAttachmentsInBatches uploads attachments in batches respecting API limits
549+
// Returns uploaded IDs and errors
550+
func (c *V2Converter) uploadAttachmentsInBatches(ctx context.Context, toUpload []attachmentToUpload) ([]string, []string) {
551+
const (
552+
maxFileSize = 32 * 1024 * 1024 // 32 MB per file
553+
maxRequestSize = 128 * 1024 * 1024 // 128 MB per request
554+
maxFilesPerBatch = 20 // 20 files per request
555+
)
556+
557+
var uploadedIDs []string
558+
var errors []string
559+
560+
// Group files into batches
561+
var currentBatch []attachmentToUpload
562+
var currentBatchSize int64
563+
564+
uploadBatch := func(batch []attachmentToUpload) {
565+
if len(batch) == 0 {
566+
return
567+
}
568+
569+
files := make([]*os.File, 0, len(batch))
570+
for _, item := range batch {
571+
files = append(files, item.file)
572+
}
573+
574+
logging.Debug("Uploading batch of %d files (total size: %d bytes)", len(files), currentBatchSize)
575+
576+
hashes, err := c.uploader.UploadAttachment(ctx, c.projectCode, files)
577+
if err != nil {
578+
logging.Warn("Warning: Failed to upload batch of %d files: %v", len(files), err)
579+
for _, item := range batch {
580+
errors = append(errors, fmt.Sprintf("batch upload failed for %s: %v", item.attachment.FileName, err))
581+
if item.isTempFile {
582+
os.Remove(item.file.Name())
583+
}
584+
item.file.Close()
585+
}
586+
return
587+
}
588+
589+
// Match hashes to files (API returns hashes in the same order as files were sent)
590+
if len(hashes) != len(batch) {
591+
logging.Warn("Warning: Expected %d hashes from API, got %d", len(batch), len(hashes))
592+
// Use available hashes, mark missing ones as errors
593+
for i, item := range batch {
594+
if i < len(hashes) {
595+
uploadedIDs = append(uploadedIDs, hashes[i])
596+
logging.Debug("Successfully uploaded attachment, got hash: %s", hashes[i])
597+
} else {
598+
errors = append(errors, fmt.Sprintf("no hash returned for %s", item.attachment.FileName))
599+
}
600+
if item.isTempFile {
601+
os.Remove(item.file.Name())
602+
}
603+
item.file.Close()
604+
}
605+
} else {
606+
uploadedIDs = append(uploadedIDs, hashes...)
607+
for i, hash := range hashes {
608+
logging.Debug("Successfully uploaded attachment %s, got hash: %s", batch[i].attachment.FileName, hash)
609+
if batch[i].isTempFile {
610+
os.Remove(batch[i].file.Name())
611+
}
612+
batch[i].file.Close()
613+
}
614+
}
615+
}
616+
617+
// Process files and create batches
618+
for _, item := range toUpload {
619+
fileInfo, err := item.file.Stat()
620+
if err != nil {
621+
logging.Warn("Warning: Failed to get file info for %s: %v, skipping", item.attachment.FileName, err)
622+
if item.isTempFile {
623+
os.Remove(item.file.Name())
624+
}
625+
item.file.Close()
626+
errors = append(errors, fmt.Sprintf("file stat failed for %s: %v", item.attachment.FileName, err))
627+
continue
628+
}
629+
630+
fileSize := fileInfo.Size()
631+
632+
// Check if adding this file would exceed limits
633+
wouldExceedSize := currentBatchSize+fileSize > maxRequestSize
634+
wouldExceedCount := len(currentBatch) >= maxFilesPerBatch
635+
636+
// If current batch is full or would exceed limits, upload it first
637+
if wouldExceedSize || wouldExceedCount {
638+
if len(currentBatch) > 0 {
639+
uploadBatch(currentBatch)
640+
currentBatch = nil
641+
currentBatchSize = 0
642+
}
643+
644+
// If single file exceeds request size limit, skip it (already checked file size limit)
645+
if fileSize > maxRequestSize {
646+
logging.Warn("Warning: File %s exceeds 128 MB request limit (%d bytes), skipping", item.attachment.FileName, fileSize)
647+
if item.isTempFile {
648+
os.Remove(item.file.Name())
649+
}
650+
item.file.Close()
651+
errors = append(errors, fmt.Sprintf("file %s exceeds 128 MB request limit", item.attachment.FileName))
652+
continue
653+
}
654+
}
655+
656+
// Add file to current batch
657+
currentBatch = append(currentBatch, item)
658+
currentBatchSize += fileSize
659+
}
660+
661+
// Upload remaining batch
662+
if len(currentBatch) > 0 {
663+
uploadBatch(currentBatch)
664+
}
665+
666+
return uploadedIDs, errors
667+
}
668+
484669
// convertSteps converts all steps from domain to API format
485670
func (c *V2Converter) convertSteps(steps []domain.TestStep) ([]api_v2_client.ResultStep, error) {
486671
var apiSteps []api_v2_client.ResultStep

0 commit comments

Comments
 (0)