@@ -311,4 +311,325 @@ public function test_sideload_item_year_month_based_folders_page_post_type() {
311311 $ this ->assertStringNotContainsString ( '2017/02 ' , $ data ['source_url ' ] );
312312 $ this ->assertStringContainsString ( $ subdir , $ data ['source_url ' ] );
313313 }
314+
315+ /**
316+ * Verifies that exif_orientation field is returned in REST API response.
317+ *
318+ * @covers ::prepare_item_for_response
319+ * @covers ::get_item_schema
320+ */
321+ public function test_exif_orientation_field_returned_in_response () {
322+ wp_set_current_user ( self ::$ admin_id );
323+
324+ $ request = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
325+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
326+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=canola.jpg ' );
327+ $ request ->set_param ( 'generate_sub_sizes ' , false );
328+
329+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/canola.jpg ' ) );
330+ $ response = rest_get_server ()->dispatch ( $ request );
331+ $ data = $ response ->get_data ();
332+
333+ $ this ->assertSame ( 201 , $ response ->get_status () );
334+ $ this ->assertArrayHasKey ( 'exif_orientation ' , $ data );
335+ // canola.jpg has no EXIF orientation, so it should default to 1.
336+ $ this ->assertSame ( 1 , $ data ['exif_orientation ' ] );
337+ }
338+
339+ /**
340+ * Verifies that exif_orientation field is returned for image with non-1 orientation.
341+ *
342+ * Uses test-image-upside-down.jpg which has EXIF orientation value 3 (180° rotation).
343+ *
344+ * @covers ::prepare_item_for_response
345+ * @covers ::create_item
346+ * @requires extension exif
347+ */
348+ public function test_exif_orientation_returned_for_rotated_image () {
349+ wp_set_current_user ( self ::$ admin_id );
350+
351+ $ request = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
352+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
353+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=test-image-upside-down.jpg ' );
354+ $ request ->set_param ( 'generate_sub_sizes ' , false );
355+
356+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/test-image-upside-down.jpg ' ) );
357+ $ response = rest_get_server ()->dispatch ( $ request );
358+ $ data = $ response ->get_data ();
359+
360+ $ this ->assertSame ( 201 , $ response ->get_status () );
361+ $ this ->assertArrayHasKey ( 'exif_orientation ' , $ data );
362+ // test-image-upside-down.jpg has EXIF orientation 3 (180° rotation).
363+ $ this ->assertSame ( 3 , $ data ['exif_orientation ' ] );
364+ }
365+
366+ /**
367+ * Verifies that server-side EXIF rotation is disabled when generate_sub_sizes is false.
368+ *
369+ * When client-side processing is enabled (generate_sub_sizes=false), the server should
370+ * NOT rotate the image based on EXIF orientation. The original orientation value should
371+ * be preserved in metadata so the client can handle rotation.
372+ *
373+ * @covers ::create_item
374+ * @requires extension exif
375+ */
376+ public function test_server_side_exif_rotation_disabled_for_client_side_processing () {
377+ wp_set_current_user ( self ::$ admin_id );
378+
379+ $ request = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
380+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
381+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=test-image-upside-down.jpg ' );
382+ $ request ->set_param ( 'generate_sub_sizes ' , false );
383+
384+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/test-image-upside-down.jpg ' ) );
385+ $ response = rest_get_server ()->dispatch ( $ request );
386+ $ data = $ response ->get_data ();
387+
388+ $ this ->assertSame ( 201 , $ response ->get_status () );
389+
390+ // Get the attachment metadata directly from the database.
391+ $ metadata = wp_get_attachment_metadata ( $ data ['id ' ], true );
392+
393+ // The orientation should still be 3 (not reset to 1) because server-side rotation was disabled.
394+ $ this ->assertArrayHasKey ( 'image_meta ' , $ metadata );
395+ $ this ->assertArrayHasKey ( 'orientation ' , $ metadata ['image_meta ' ] );
396+ $ this ->assertSame ( '3 ' , $ metadata ['image_meta ' ]['orientation ' ] );
397+
398+ // The exif_orientation in the REST response should also be 3.
399+ $ this ->assertSame ( 3 , $ data ['exif_orientation ' ] );
400+ }
401+
402+ /**
403+ * Verifies that full EXIF metadata is extracted and stored during client-side upload flow.
404+ *
405+ * Uses 2004-07-22-DSC_0008.jpg which has rich EXIF data from a Nikon D70 camera.
406+ *
407+ * @covers ::create_item
408+ * @covers ::prepare_item_for_response
409+ * @requires extension exif
410+ */
411+ public function test_full_exif_metadata_extracted_for_client_side_upload () {
412+ wp_set_current_user ( self ::$ admin_id );
413+
414+ $ request = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
415+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
416+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=2004-07-22-DSC_0008.jpg ' );
417+ $ request ->set_param ( 'generate_sub_sizes ' , false );
418+
419+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg ' ) );
420+ $ response = rest_get_server ()->dispatch ( $ request );
421+ $ data = $ response ->get_data ();
422+
423+ $ this ->assertSame ( 201 , $ response ->get_status () );
424+ $ this ->assertArrayHasKey ( 'media_details ' , $ data );
425+ $ this ->assertArrayHasKey ( 'image_meta ' , $ data ['media_details ' ] );
426+
427+ $ image_meta = $ data ['media_details ' ]['image_meta ' ];
428+
429+ // Verify the full EXIF data is extracted (same data as server-side upload).
430+ $ this ->assertSame ( '6.3 ' , $ image_meta ['aperture ' ] );
431+ $ this ->assertSame ( 'NIKON D70 ' , $ image_meta ['camera ' ] );
432+ $ this ->assertSame ( '27 ' , $ image_meta ['focal_length ' ] );
433+ $ this ->assertSame ( '400 ' , $ image_meta ['iso ' ] );
434+ // Verify timestamp is set (Nikon D70 image has created_timestamp).
435+ $ this ->assertNotEmpty ( $ image_meta ['created_timestamp ' ] );
436+ }
437+
438+ /**
439+ * Verifies that EXIF metadata with IPTC data is extracted correctly.
440+ *
441+ * Uses 2004-07-22-DSC_0007.jpg which has both EXIF and IPTC data.
442+ *
443+ * @covers ::create_item
444+ * @covers ::prepare_item_for_response
445+ * @requires extension exif
446+ */
447+ public function test_exif_and_iptc_metadata_extracted () {
448+ wp_set_current_user ( self ::$ admin_id );
449+
450+ $ request = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
451+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
452+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=2004-07-22-DSC_0007.jpg ' );
453+ $ request ->set_param ( 'generate_sub_sizes ' , false );
454+
455+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/2004-07-22-DSC_0007.jpg ' ) );
456+ $ response = rest_get_server ()->dispatch ( $ request );
457+ $ data = $ response ->get_data ();
458+
459+ $ this ->assertSame ( 201 , $ response ->get_status () );
460+
461+ $ image_meta = $ data ['media_details ' ]['image_meta ' ];
462+
463+ // Verify EXIF data from camera.
464+ $ this ->assertSame ( '6.3 ' , $ image_meta ['aperture ' ] );
465+ $ this ->assertSame ( 'NIKON D70 ' , $ image_meta ['camera ' ] );
466+ $ this ->assertSame ( '18 ' , $ image_meta ['focal_length ' ] );
467+ $ this ->assertSame ( '200 ' , $ image_meta ['iso ' ] );
468+
469+ // Verify IPTC data.
470+ $ this ->assertSame ( 'IPTC Creator ' , $ image_meta ['credit ' ] );
471+ $ this ->assertSame ( 'IPTC Caption ' , $ image_meta ['caption ' ] );
472+ $ this ->assertSame ( 'IPTC Copyright ' , $ image_meta ['copyright ' ] );
473+ $ this ->assertSame ( 'IPTC Headline ' , $ image_meta ['title ' ] );
474+ }
475+
476+ /**
477+ * Verifies that sideloading sub-sizes preserves the original image_meta.
478+ *
479+ * @covers ::sideload_item
480+ * @requires extension exif
481+ */
482+ public function test_sideload_preserves_image_meta () {
483+ wp_set_current_user ( self ::$ admin_id );
484+
485+ // First, upload an image with EXIF data using client-side upload flow.
486+ $ request = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
487+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
488+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=2004-07-22-DSC_0008.jpg ' );
489+ $ request ->set_param ( 'generate_sub_sizes ' , false );
490+
491+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg ' ) );
492+ $ response = rest_get_server ()->dispatch ( $ request );
493+ $ data = $ response ->get_data ();
494+ $ attachment_id = $ data ['id ' ];
495+
496+ // Record the original image_meta.
497+ $ original_image_meta = $ data ['media_details ' ]['image_meta ' ];
498+
499+ // Now sideload a sub-size.
500+ $ request = new WP_REST_Request ( 'POST ' , "/wp/v2/media/ $ attachment_id/sideload " );
501+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
502+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=2004-07-22-DSC_0008-150x150.jpg ' );
503+ $ request ->set_param ( 'image_size ' , 'thumbnail ' );
504+
505+ // Use a smaller image for the sub-size (dimensions don't matter for this test).
506+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/canola.jpg ' ) );
507+ $ response = rest_get_server ()->dispatch ( $ request );
508+ $ data = $ response ->get_data ();
509+
510+ $ this ->assertSame ( 200 , $ response ->get_status () );
511+
512+ // Verify the image_meta is preserved after sideloading.
513+ $ this ->assertArrayHasKey ( 'image_meta ' , $ data ['media_details ' ] );
514+ $ sideloaded_image_meta = $ data ['media_details ' ]['image_meta ' ];
515+
516+ // The EXIF data should be unchanged.
517+ $ this ->assertSame ( $ original_image_meta ['aperture ' ], $ sideloaded_image_meta ['aperture ' ] );
518+ $ this ->assertSame ( $ original_image_meta ['camera ' ], $ sideloaded_image_meta ['camera ' ] );
519+ $ this ->assertSame ( $ original_image_meta ['focal_length ' ], $ sideloaded_image_meta ['focal_length ' ] );
520+ $ this ->assertSame ( $ original_image_meta ['iso ' ], $ sideloaded_image_meta ['iso ' ] );
521+ }
522+
523+ /**
524+ * Verifies that sideloaded sub-sizes include expected metadata fields.
525+ *
526+ * Sub-sizes should have file, width, height, mime-type, and filesize in their metadata.
527+ *
528+ * @covers ::sideload_item
529+ */
530+ public function test_sideloaded_subsize_has_complete_metadata () {
531+ wp_set_current_user ( self ::$ admin_id );
532+
533+ $ attachment_id = self ::factory ()->attachment ->create_object (
534+ DIR_TESTDATA . '/images/canola.jpg ' ,
535+ 0 ,
536+ array (
537+ 'post_mime_type ' => 'image/jpeg ' ,
538+ )
539+ );
540+
541+ wp_update_attachment_metadata (
542+ $ attachment_id ,
543+ wp_generate_attachment_metadata ( $ attachment_id , DIR_TESTDATA . '/images/canola.jpg ' )
544+ );
545+
546+ $ request = new WP_REST_Request ( 'POST ' , "/wp/v2/media/ $ attachment_id/sideload " );
547+ $ request ->set_header ( 'Content-Type ' , 'image/jpeg ' );
548+ $ request ->set_header ( 'Content-Disposition ' , 'attachment; filename=canola-300x200.jpg ' );
549+ $ request ->set_param ( 'image_size ' , 'medium ' );
550+
551+ $ request ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/canola.jpg ' ) );
552+ $ response = rest_get_server ()->dispatch ( $ request );
553+ $ data = $ response ->get_data ();
554+
555+ $ this ->assertSame ( 200 , $ response ->get_status () );
556+ $ this ->assertArrayHasKey ( 'sizes ' , $ data ['media_details ' ] );
557+ $ this ->assertArrayHasKey ( 'medium ' , $ data ['media_details ' ]['sizes ' ] );
558+
559+ $ medium_size = $ data ['media_details ' ]['sizes ' ]['medium ' ];
560+
561+ // Verify all expected metadata fields are present for the sub-size.
562+ $ this ->assertArrayHasKey ( 'file ' , $ medium_size );
563+ $ this ->assertArrayHasKey ( 'width ' , $ medium_size );
564+ $ this ->assertArrayHasKey ( 'height ' , $ medium_size );
565+ $ this ->assertArrayHasKey ( 'mime_type ' , $ medium_size );
566+ $ this ->assertArrayHasKey ( 'filesize ' , $ medium_size );
567+
568+ $ this ->assertSame ( 'canola-300x200.jpg ' , $ medium_size ['file ' ] );
569+ $ this ->assertSame ( 'image/jpeg ' , $ medium_size ['mime_type ' ] );
570+ $ this ->assertGreaterThan ( 0 , $ medium_size ['filesize ' ] );
571+ }
572+
573+ /**
574+ * Verifies that exif_orientation is in the schema for images.
575+ *
576+ * @covers ::get_item_schema
577+ */
578+ public function test_exif_orientation_in_schema () {
579+ $ controller = new Gutenberg_REST_Attachments_Controller ( 'attachment ' );
580+ $ schema = $ controller ->get_item_schema ();
581+
582+ $ this ->assertArrayHasKey ( 'exif_orientation ' , $ schema ['properties ' ] );
583+ $ this ->assertSame ( 'integer ' , $ schema ['properties ' ]['exif_orientation ' ]['type ' ] );
584+ $ this ->assertContains ( 'edit ' , $ schema ['properties ' ]['exif_orientation ' ]['context ' ] );
585+ $ this ->assertTrue ( $ schema ['properties ' ]['exif_orientation ' ]['readonly ' ] );
586+ }
587+
588+ /**
589+ * Verifies metadata consistency between server-side and client-side upload flows.
590+ *
591+ * The same image uploaded with server-side processing (generate_sub_sizes=true)
592+ * should have the same image_meta as when uploaded with client-side processing
593+ * (generate_sub_sizes=false).
594+ *
595+ * @covers ::create_item
596+ * @requires extension exif
597+ */
598+ public function test_metadata_consistency_between_upload_flows () {
599+ wp_set_current_user ( self ::$ admin_id );
600+
601+ // Upload with server-side processing (default).
602+ $ request_server = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
603+ $ request_server ->set_header ( 'Content-Type ' , 'image/jpeg ' );
604+ $ request_server ->set_header ( 'Content-Disposition ' , 'attachment; filename=server-side-upload.jpg ' );
605+ $ request_server ->set_param ( 'generate_sub_sizes ' , true );
606+
607+ $ request_server ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg ' ) );
608+ $ response_server = rest_get_server ()->dispatch ( $ request_server );
609+ $ data_server = $ response_server ->get_data ();
610+
611+ // Upload with client-side processing.
612+ $ request_client = new WP_REST_Request ( 'POST ' , '/wp/v2/media ' );
613+ $ request_client ->set_header ( 'Content-Type ' , 'image/jpeg ' );
614+ $ request_client ->set_header ( 'Content-Disposition ' , 'attachment; filename=client-side-upload.jpg ' );
615+ $ request_client ->set_param ( 'generate_sub_sizes ' , false );
616+
617+ $ request_client ->set_body ( file_get_contents ( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg ' ) );
618+ $ response_client = rest_get_server ()->dispatch ( $ request_client );
619+ $ data_client = $ response_client ->get_data ();
620+
621+ $ this ->assertSame ( 201 , $ response_server ->get_status () );
622+ $ this ->assertSame ( 201 , $ response_client ->get_status () );
623+
624+ $ meta_server = $ data_server ['media_details ' ]['image_meta ' ];
625+ $ meta_client = $ data_client ['media_details ' ]['image_meta ' ];
626+
627+ // The core EXIF fields should be identical.
628+ $ this ->assertSame ( $ meta_server ['aperture ' ], $ meta_client ['aperture ' ] );
629+ $ this ->assertSame ( $ meta_server ['camera ' ], $ meta_client ['camera ' ] );
630+ $ this ->assertSame ( $ meta_server ['focal_length ' ], $ meta_client ['focal_length ' ] );
631+ $ this ->assertSame ( $ meta_server ['iso ' ], $ meta_client ['iso ' ] );
632+ $ this ->assertSame ( $ meta_server ['shutter_speed ' ], $ meta_client ['shutter_speed ' ] );
633+ $ this ->assertSame ( $ meta_server ['created_timestamp ' ], $ meta_client ['created_timestamp ' ] );
634+ }
314635}
0 commit comments