@@ -14,8 +14,24 @@ import (
1414)
1515
1616// AttachmentUploader interface for uploading attachments
17+ // Returns a slice of hashes corresponding to the uploaded files
1718type 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
372399func (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
485670func (c * V2Converter ) convertSteps (steps []domain.TestStep ) ([]api_v2_client.ResultStep , error ) {
486671 var apiSteps []api_v2_client.ResultStep
0 commit comments