diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..c6f5e277 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,53 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read + services: + database: + image: mysql:latest + env: + MYSQL_DATABASE: wordpress_tests + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Setup PHP version + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + extensions: simplexml, mysql + tools: phpunit-polyfills:1.1 + coverage: none + + - name: Install WordPress test suite prerequisites + run: sudo apt-get update && sudo apt-get install -y subversion default-mysql-client + + - name: Install WordPress Test Suite + run: composer install-wp-tests + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Verify PHPUnit is available + run: composer phpunit -- --version diff --git a/assets/src/dashboard/parts/connected/settings/OffloadMedia.js b/assets/src/dashboard/parts/connected/settings/OffloadMedia.js index 896c9102..91c5a325 100644 --- a/assets/src/dashboard/parts/connected/settings/OffloadMedia.js +++ b/assets/src/dashboard/parts/connected/settings/OffloadMedia.js @@ -139,8 +139,10 @@ const OffloadMedia = ({ settings, canSave, setSettings, setCanSave }) => { setCheckedOffloadConflicts( false ); setOffloadConflicts([]); - checkOffloadConflicts( response => { + checkOffloadConflicts( async response => { if ( 0 === response.data.length ) { + await clearOffloadErrors( 'rollback_images' ); + const nextSettings = { ...settings }; nextSettings['show_offload_finish_notice'] = ''; nextSettings['rollback_status'] = 'enabled'; diff --git a/assets/src/dashboard/utils/api.js b/assets/src/dashboard/utils/api.js index 63f7dfd8..4721bf30 100644 --- a/assets/src/dashboard/utils/api.js +++ b/assets/src/dashboard/utils/api.js @@ -589,10 +589,10 @@ export const callSync = ( data ) => { }); }; -export const clearOffloadErrors = async() => { +export const clearOffloadErrors = async( action = 'offload_images' ) => { try { return await apiFetch({ - path: optimoleDashboardApp.routes['clear_offload_errors'], + path: addQueryArgs( optimoleDashboardApp.routes['clear_offload_errors'], { action }), method: 'GET' }); } catch ( error ) { diff --git a/inc/cli/cli_media.php b/inc/cli/cli_media.php index 0f19204f..8aa31e12 100644 --- a/inc/cli/cli_media.php +++ b/inc/cli/cli_media.php @@ -48,6 +48,9 @@ private function update_images_template( $action ) { } Optml_Media_Offload::clear_offload_errors_meta(); + if ( $action === 'rollback' ) { + Optml_Media_Offload::clear_rollback_errors_meta(); + } WP_CLI::line( $strings[ $action ]['info'] ); $number_of_images_for = 'offload_images'; if ( $action === 'rollback' ) { diff --git a/inc/media_offload.php b/inc/media_offload.php index ac40c370..99955531 100644 --- a/inc/media_offload.php +++ b/inc/media_offload.php @@ -2673,15 +2673,57 @@ private function get_offloaded_attachment_url( $attachment_id, $url ) { /** * Cleanup the offload errors meta. + * + * @param string $meta_key The meta key to delete. Defaults to the offload error key. + * + * @return int|bool Number of rows affected/selected or false on error. */ - public static function clear_offload_errors_meta() { + public static function clear_offload_errors_meta( $meta_key = '' ) { global $wpdb; - return $wpdb->query( + if ( empty( $meta_key ) ) { + $meta_key = self::META_KEYS['offload_error']; + } + + // Collect the affected attachments before the bulk delete so their object + // caches can be invalidated. A raw DELETE bypasses the meta/query caches, + // which would otherwise leave stale WP_Query results for subsequent queries. + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", + $meta_key + ) + ); + + $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", - self::META_KEYS['offload_error'] + $meta_key ) ); + + foreach ( $post_ids as $post_id ) { + wp_cache_delete( (int) $post_id, 'post_meta' ); + } + + // Bump the posts last_changed so cached WP_Query results (which are keyed + // on it) are recomputed on the next query. The raw DELETE above does not + // touch the object cache, so without this the retried rollback/offload + // query could return a stale set that still excludes the cleared posts. + wp_cache_set( 'last_changed', microtime(), 'posts' ); + + return $result; + } + + /** + * Cleanup the rollback errors meta. + * + * Used when the user retries the rollback process so previously errored + * attachments are considered again for restore. + * + * @return int|bool Number of rows affected/selected or false on error. + */ + public static function clear_rollback_errors_meta() { + return self::clear_offload_errors_meta( self::META_KEYS['rollback_error'] ); } } diff --git a/inc/rest.php b/inc/rest.php index 38cbaf4e..4a5ff2ab 100644 --- a/inc/rest.php +++ b/inc/rest.php @@ -844,7 +844,13 @@ public function number_of_images_and_pages( WP_REST_Request $request ) { * @return WP_REST_Response */ public function clear_offload_errors( WP_REST_Request $request ) { - $delete_count = Optml_Media_Offload::clear_offload_errors_meta(); + $action = $request->get_param( 'action' ); + + if ( 'rollback_images' === $action ) { + $delete_count = Optml_Media_Offload::clear_rollback_errors_meta(); + } else { + $delete_count = Optml_Media_Offload::clear_offload_errors_meta(); + } return $this->response( [ 'success' => $delete_count ] ); } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e60e4248..3141d694 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2034,12 +2034,6 @@ parameters: count: 1 path: inc/media_offload.php - - - message: '#^Method Optml_Media_Offload\:\:clear_offload_errors_meta\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: inc/media_offload.php - - message: '#^Method Optml_Media_Offload\:\:delete_attachment_from_server\(\) has no return type specified\.$#' identifier: missingType.return diff --git a/tests/test-media.php b/tests/test-media.php index 9b8148fd..4552dd91 100644 --- a/tests/test-media.php +++ b/tests/test-media.php @@ -356,6 +356,41 @@ public function test_image_rollback() { $this->assertStringContainsString( '-300x200', $image_meta['sizes']['medium']['file'] ); $this->assertStringContainsString( '-150x150', $image_meta['sizes']['thumbnail']['file'] ); } + + public function test_rollback_retry_clears_errors() { + + // Use a dedicated attachment with fully controlled meta so the assertions do + // not depend on the offload state produced during setUp. + $attachment_id = self::factory()->post->create( + [ + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + 'post_status' => 'inherit', + ] + ); + + // Mark it as offloaded so it is eligible for rollback. + update_post_meta( $attachment_id, Optml_Media_Offload::META_KEYS['offloaded'], 'true' ); + + $rollback_query = function () use ( $attachment_id ) { + $args = Optml_Media_Offload::get_images_or_pages_query_args( -1, 'rollback_images', true ); + $args['post__in'] = [ $attachment_id ]; + + return ( new WP_Query( $args ) )->posts; + }; + + // Without a rollback error the attachment is eligible for restore. + $this->assertContains( $attachment_id, $rollback_query() ); + + // Simulate a failed rollback attempt which should exclude it from selection. + update_post_meta( $attachment_id, Optml_Media_Offload::META_KEYS['rollback_error'], 'true' ); + $this->assertNotContains( $attachment_id, $rollback_query() ); + + // Retrying the rollback should clear the rollback errors so the attachment is eligible again. + Optml_Media_Offload::clear_rollback_errors_meta(); + $this->assertEmpty( get_post_meta( $attachment_id, Optml_Media_Offload::META_KEYS['rollback_error'], true ) ); + $this->assertContains( $attachment_id, $rollback_query() ); + } public function test_custom_post_image_extraction () { $content = '[fusion_builder_container type="flex" hundred_percent="no" hundred_percent_height="no" hundred_percent_height_scroll="no" align_content="stretch" flex_align_items="flex-start" flex_justify_content="flex-start" hundred_percent_height_center_content="yes" equal_height_columns="no" container_tag="div" hide_on_mobile="small-visibility,medium-visibility,large-visibility" status="published" border_style="solid" box_shadow="no" box_shadow_blur="0" box_shadow_spread="0" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" enable_mobile="no" parallax_speed="0.3" background_blend_mode="none" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" absolute="off" absolute_devices="small,medium,large" sticky="off" sticky_devices="small-visibility,medium-visibility,large-visibility" sticky_transition_offset="0" scroll_offset="0" animation_direction="left" animation_speed="0.3" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0"][fusion_builder_row][fusion_builder_column type="1_3" type="1_3" align_self="auto" content_layout="column" align_content="flex-start" valign_content="flex-start" content_wrap="wrap" spacing="" center_content="no" link="" target="_self" min_height="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" type_medium="" type_small="" order_medium="0" order_small="0" dimension_spacing_medium="" dimension_spacing_small="" dimension_spacing="" dimension_margin_medium="" dimension_margin_small="" margin_top="" margin_bottom="" padding_medium="" padding_small="" padding_top="" padding_right="" padding_bottom="" padding_left="" hover_type="none" border_sizes="" border_color="" border_style="solid" border_radius="" box_shadow="no" dimension_box_shadow="" box_shadow_blur="0" box_shadow_spread="0" box_shadow_color="" box_shadow_style="" background_type="single" gradient_start_color="" gradient_end_color="" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_color="" background_image="" background_image_id="" background_position="left top" background_repeat="no-repeat" background_blend_mode="none" render_logics="" filter_type="regular" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" last="no" border_position="all"][fusion_imageframe image_id="144|full" max_width="" sticky_max_width="" style_type="" blur="" stylecolor="" hover_type="none" bordersize="" bordercolor="" borderradius="" align_medium="none" align_small="none" align="none" margin_top="" margin_right="" margin_bottom="" margin_left="" lightbox="no" gallery_id="" lightbox_image="" lightbox_image_id="" alt="" link="" linktarget="_self" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0"]http://35f86c81ba7c.ngrok.io/wp-content/uploads/2021/04/2AAPaNcjDJQ.jpg[/fusion_imageframe][/fusion_builder_column][fusion_builder_column type="2_3" type="2_3" align_self="auto" content_layout="column" align_content="flex-start" valign_content="flex-start" content_wrap="wrap" spacing="" center_content="no" link="" target="_self" min_height="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" type_medium="" type_small="" order_medium="0" order_small="0" dimension_spacing_medium="" dimension_spacing_small="" dimension_spacing="" dimension_margin_medium="" dimension_margin_small="" margin_top="" margin_bottom="" padding_medium="" padding_small="" padding_top="" padding_right="" padding_bottom="" padding_left="" hover_type="none" border_sizes="" border_color="" border_style="solid" border_radius="" box_shadow="no" dimension_box_shadow="" box_shadow_blur="0" box_shadow_spread="0" box_shadow_color="" box_shadow_style="" background_type="single" gradient_start_color="" gradient_end_color="" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_color="" background_image="" background_image_id="" background_position="left top" background_repeat="no-repeat" background_blend_mode="none" render_logics="" filter_type="regular" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" last="no" border_position="all" element_content=""][fusion_imageframe image_id="180|fusion-1200" max_width="" sticky_max_width="" style_type="" blur="" stylecolor="" hover_type="none" bordersize="" bordercolor="" borderradius="" align_medium="none" align_small="none" align="none" lightbox="no" gallery_id="" lightbox_image="" lightbox_image_id="" alt="" link="" linktarget="_self" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100"