@@ -184,35 +184,219 @@ public function isTestRunExist(string $code, int $runId): bool
184184 }
185185 }
186186
187- public function uploadAttachment (string $ code , Attachment $ attachment ): ?string
187+ /**
188+ * Upload one or multiple attachments
189+ *
190+ * Limitations:
191+ * - Up to 32 MB per file
192+ * - Up to 128 MB per single request
193+ * - Up to 20 files per single request
194+ *
195+ * @param string $code Project code
196+ * @param Attachment|Attachment[] $attachments Single attachment or array of attachments
197+ * @return string|string[]|null Hash(es) of uploaded attachment(s) or null on failure
198+ */
199+ public function uploadAttachment (string $ code , Attachment |array $ attachments ): string |array |null
188200 {
201+ // Normalize to array
202+ $ attachmentsArray = is_array ($ attachments ) ? $ attachments : [$ attachments ];
203+
204+ // Check for empty array
205+ if (empty ($ attachmentsArray )) {
206+ $ this ->logger ->warning ('Empty attachments array provided ' );
207+ return is_array ($ attachments ) ? [] : null ;
208+ }
209+
189210 try {
190- $ this ->logger ->debug ('Upload attachment: ' . json_encode ($ attachment ));
211+ $ this ->logger ->debug ('Upload ' . count ($ attachmentsArray ) . ' attachment(s) ' );
212+
213+ // Filter and validate individual file constraints (skip invalid files)
214+ $ validAttachments = $ this ->filterValidAttachments ($ attachmentsArray );
215+
216+ if (empty ($ validAttachments )) {
217+ $ this ->logger ->warning ('No valid attachments to upload after filtering ' );
218+ return is_array ($ attachments ) ? [] : null ;
219+ }
191220
221+ // Split into batches
222+ $ batches = $ this ->splitIntoBatches ($ validAttachments );
223+ $ this ->logger ->debug ('Split into ' . count ($ batches ) . ' batch(es) ' );
224+
225+ $ allHashes = [];
192226 $ attachApi = new AttachmentsApi ($ this ->client , $ this ->clientConfig );
193- if ($ attachment ->path ) {
194- $ attachmentId = $ attachApi ->uploadAttachment ($ code , new SplFileObject ($ attachment ->path ));
195- } elseif ($ attachment ->content ) {
196- $ filepath = rtrim (getcwd (), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $ attachment ->title ;
197- if (file_put_contents ($ filepath , $ attachment ->content ) === false ) {
198- $ this ->logger ->error ('Can not save attachment: ' . $ filepath );
199- return null ;
227+
228+ // Upload each batch
229+ foreach ($ batches as $ batchIndex => $ batch ) {
230+ $ this ->logger ->debug ('Uploading batch ' . ($ batchIndex + 1 ) . '/ ' . count ($ batches ) . ' with ' . count ($ batch ) . ' file(s) ' );
231+
232+ $ fileObjects = [];
233+ $ tempFiles = [];
234+
235+ // Prepare files for this batch
236+ foreach ($ batch as $ attachment ) {
237+ if ($ attachment ->path ) {
238+ $ fileObjects [] = new SplFileObject ($ attachment ->path );
239+ } elseif ($ attachment ->content ) {
240+ $ filepath = sys_get_temp_dir () . DIRECTORY_SEPARATOR . uniqid ('qase_attach_ ' , true ) . '_ ' . ($ attachment ->title ?? 'attachment ' );
241+ if (file_put_contents ($ filepath , $ attachment ->content ) === false ) {
242+ $ this ->logger ->error ('Can not save attachment: ' . $ filepath );
243+ // Clean up already created temp files
244+ foreach ($ tempFiles as $ tempFile ) {
245+ if (file_exists ($ tempFile )) {
246+ unlink ($ tempFile );
247+ }
248+ }
249+ return null ;
250+ }
251+ $ fileObjects [] = new SplFileObject ($ filepath );
252+ $ tempFiles [] = $ filepath ;
253+ } else {
254+ $ this ->logger ->error ('Attachment has neither path nor content ' );
255+ // Clean up temp files
256+ foreach ($ tempFiles as $ tempFile ) {
257+ if (file_exists ($ tempFile )) {
258+ unlink ($ tempFile );
259+ }
260+ }
261+ return null ;
262+ }
200263 }
201- $ attachmentId = $ attachApi ->uploadAttachment ($ code , new SplFileObject ($ filepath ));
202- if (unlink ($ filepath ) === false ) {
203- $ this ->logger ->error ('Can not remove attachment: ' . $ filepath );
264+
265+ // Upload batch
266+ $ result = $ attachApi ->uploadAttachment ($ code , $ fileObjects );
267+
268+ // Clean up temp files for this batch
269+ foreach ($ tempFiles as $ tempFile ) {
270+ if (file_exists ($ tempFile ) && unlink ($ tempFile ) === false ) {
271+ $ this ->logger ->error ('Can not remove temporary attachment: ' . $ tempFile );
272+ }
273+ }
274+
275+ // Extract hashes from result
276+ if ($ result && $ result ->getResult ()) {
277+ foreach ($ result ->getResult () as $ attachmentResult ) {
278+ $ allHashes [] = $ attachmentResult ->getHash ();
279+ }
204280 }
205- } else {
206- return null ;
207281 }
208282
209- return $ attachmentId ->getResult ()[0 ]->getHash ();
283+ // Return single hash if single attachment was provided, array otherwise
284+ if (!is_array ($ attachments )) {
285+ return $ allHashes [0 ] ?? null ;
286+ }
287+
288+ return $ allHashes ;
210289 } catch (Exception $ e ) {
211- $ this ->logger ->error ('Failed to upload attachment: ' . $ e ->getMessage ());
290+ $ this ->logger ->error ('Failed to upload attachment(s) : ' . $ e ->getMessage ());
212291 return null ;
213292 }
214293 }
215294
295+ /**
296+ * Filter attachments and return only valid ones, logging errors for invalid files
297+ *
298+ * @param Attachment[] $attachments
299+ * @return Attachment[] Array of valid attachments
300+ */
301+ private function filterValidAttachments (array $ attachments ): array
302+ {
303+ $ maxFileSize = 32 * 1024 * 1024 ; // 32 MB
304+ $ validAttachments = [];
305+
306+ foreach ($ attachments as $ index => $ attachment ) {
307+ $ fileSize = 0 ;
308+ $ fileName = $ attachment ->title ?? $ attachment ->path ?? "attachment at index {$ index }" ;
309+
310+ // Check if file has path or content
311+ if ($ attachment ->path ) {
312+ if (!file_exists ($ attachment ->path )) {
313+ $ this ->logger ->warning ("Skipping attachment ' {$ fileName }': file not found: {$ attachment ->path }" );
314+ continue ;
315+ }
316+ $ fileSize = filesize ($ attachment ->path );
317+ } elseif ($ attachment ->content ) {
318+ $ fileSize = strlen ($ attachment ->content );
319+ } else {
320+ $ this ->logger ->warning ("Skipping attachment ' {$ fileName }': has neither path nor content " );
321+ continue ;
322+ }
323+
324+ // Check file size
325+ if ($ fileSize > $ maxFileSize ) {
326+ $ fileSizeMB = round ($ fileSize / 1024 / 1024 , 2 );
327+ $ this ->logger ->warning ("Skipping attachment ' {$ fileName }': file size {$ fileSizeMB } MB exceeds maximum of 32 MB per file " );
328+ continue ;
329+ }
330+
331+ $ validAttachments [] = $ attachment ;
332+ }
333+
334+ $ skippedCount = count ($ attachments ) - count ($ validAttachments );
335+ if ($ skippedCount > 0 ) {
336+ $ this ->logger ->info ("Filtered out {$ skippedCount } invalid attachment(s), proceeding with " . count ($ validAttachments ) . " valid attachment(s) " );
337+ }
338+
339+ return $ validAttachments ;
340+ }
341+
342+ /**
343+ * Split attachments into batches respecting API constraints
344+ *
345+ * @param Attachment[] $attachments
346+ * @return Attachment[][] Array of batches
347+ */
348+ private function splitIntoBatches (array $ attachments ): array
349+ {
350+ $ maxFilesPerBatch = 20 ;
351+ $ maxSizePerBatch = 128 * 1024 * 1024 ; // 128 MB
352+
353+ $ batches = [];
354+ $ currentBatch = [];
355+ $ currentBatchSize = 0 ;
356+
357+ foreach ($ attachments as $ attachment ) {
358+ // Get file size (files should be validated already, but add safety check)
359+ $ fileSize = 0 ;
360+ if ($ attachment ->path ) {
361+ if (file_exists ($ attachment ->path )) {
362+ $ fileSize = filesize ($ attachment ->path );
363+ } else {
364+ $ this ->logger ->warning ("Skipping attachment in batch: file not found: {$ attachment ->path }" );
365+ continue ;
366+ }
367+ } elseif ($ attachment ->content ) {
368+ $ fileSize = strlen ($ attachment ->content );
369+ } else {
370+ $ this ->logger ->warning ("Skipping attachment in batch: has neither path nor content " );
371+ continue ;
372+ }
373+
374+ // Check if we need to start a new batch
375+ $ wouldExceedFileLimit = count ($ currentBatch ) >= $ maxFilesPerBatch ;
376+ $ wouldExceedSizeLimit = ($ currentBatchSize + $ fileSize ) > $ maxSizePerBatch ;
377+
378+ if ($ wouldExceedFileLimit || $ wouldExceedSizeLimit ) {
379+ // Save current batch and start a new one
380+ if (!empty ($ currentBatch )) {
381+ $ batches [] = $ currentBatch ;
382+ $ currentBatch = [];
383+ $ currentBatchSize = 0 ;
384+ }
385+ }
386+
387+ // Add file to current batch
388+ $ currentBatch [] = $ attachment ;
389+ $ currentBatchSize += $ fileSize ;
390+ }
391+
392+ // Add the last batch if it's not empty
393+ if (!empty ($ currentBatch )) {
394+ $ batches [] = $ currentBatch ;
395+ }
396+
397+ return $ batches ;
398+ }
399+
216400 public function sendResults (string $ code , int $ runId , array $ results ): void
217401 {
218402 // Use Api V2 client for sending results
0 commit comments