diff --git a/.github/changelog/2840-from-description b/.github/changelog/2840-from-description new file mode 100644 index 0000000000..983fd648e1 --- /dev/null +++ b/.github/changelog/2840-from-description @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add bulk and row action to soft delete posts from the Fediverse. diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index a04b0dd201..649fb53e58 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -15,8 +15,10 @@ use Activitypub\Moderation; use Activitypub\Scheduler\Actor; +use function Activitypub\add_to_outbox; use function Activitypub\count_followers; use function Activitypub\get_content_visibility; +use function Activitypub\get_wp_object_state; use function Activitypub\is_user_type_disabled; use function Activitypub\site_supports_blocks; use function Activitypub\user_can_activitypub; @@ -57,6 +59,15 @@ public static function init() { \add_action( 'admin_post_delete_actor_confirmed', array( self::class, 'handle_bulk_actor_delete_confirmation' ) ); \add_action( 'admin_action_activitypub_confirm_removal', array( self::class, 'handle_bulk_actor_delete_page' ) ); + // Post bulk actions for federated content. + self::register_post_bulk_actions(); + \add_action( 'admin_post_activitypub_delete_posts_confirmed', array( self::class, 'handle_bulk_post_delete_confirmation' ) ); + \add_action( 'admin_action_activitypub_confirm_post_removal', array( self::class, 'handle_bulk_post_delete_page' ) ); + \add_action( 'admin_post_activitypub_delete_post', array( self::class, 'handle_single_post_delete' ) ); + + // Register removable query args for one-time admin notices. + \add_filter( 'removable_query_args', array( self::class, 'add_removable_query_args' ) ); + if ( user_can_activitypub( \get_current_user_id() ) ) { \add_action( 'show_user_profile', array( self::class, 'add_profile' ) ); } @@ -115,6 +126,98 @@ public static function admin_notices() { 0 ) { + ?> +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ $users, - 'send_back' => $send_back, + 'type' => 'users', + 'items' => $users, + 'send_back' => $send_back, + 'checked' => false, + 'cancel_label' => \__( 'Skip', 'activitypub' ), ) ); exit; @@ -791,6 +897,245 @@ public static function process_capability_removal( $users, $remove_from_fedivers return $send_back; } + /** + * Register bulk actions for post types that support ActivityPub. + */ + public static function register_post_bulk_actions() { + $post_types = \get_post_types_by_support( 'activitypub' ); + + foreach ( $post_types as $post_type ) { + \add_filter( "bulk_actions-edit-{$post_type}", array( self::class, 'post_bulk_options' ) ); + \add_filter( "handle_bulk_actions-edit-{$post_type}", array( self::class, 'handle_post_bulk_request' ), 10, 3 ); + } + } + + /** + * Add options to the Bulk dropdown on the posts page. + * + * @param array $actions The existing bulk options. + * + * @return array The extended bulk options. + */ + public static function post_bulk_options( $actions ) { + $actions['activitypub_delete'] = __( 'Soft Delete', 'activitypub' ); + + return $actions; + } + + /** + * Handle bulk activitypub requests for posts. + * + * @param string $send_back The URL to send the user back to. + * @param string $action The requested action. + * @param array $post_ids The selected post IDs. + * + * @return string The URL to send the user back to. + */ + public static function handle_post_bulk_request( $send_back, $action, $post_ids ) { + if ( 'activitypub_delete' !== $action ) { + return $send_back; + } + + // Filter to only include federated posts. + $federated_posts = array(); + foreach ( $post_ids as $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post ) { + continue; + } + + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $state ) { + $federated_posts[] = $post_id; + } + } + + // If no federated posts, redirect back with a notice. + if ( empty( $federated_posts ) ) { + return \add_query_arg( 'activitypub_no_federated', '1', $send_back ); + } + + // Build the query args for the confirmation page. + $query_args = array( + 'action' => 'activitypub_confirm_post_removal', + 'send_back' => \rawurlencode( $send_back ), + ); + + // Add post IDs as separate parameters. + foreach ( $federated_posts as $index => $post_id ) { + $query_args[ sprintf( 'posts[%d]', $index ) ] = \absint( $post_id ); + } + + $confirmation_url = \add_query_arg( $query_args, \admin_url( 'edit.php' ) ); + + // Force redirect to confirmation page. + \wp_safe_redirect( $confirmation_url ); + exit; + } + + /** + * Handle the bulk post deletion page request directly. + */ + public static function handle_bulk_post_delete_page() { + // Check permissions. + if ( ! \current_user_can( 'edit_posts' ) ) { + \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'activitypub' ) ); + } + + // Get parameters. + // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput + $posts = \wp_unslash( $_GET['posts'] ?? array() ); + // phpcs:ignore WordPress.Security.NonceVerification + $send_back = \urldecode( \sanitize_text_field( \wp_unslash( $_GET['send_back'] ?? '' ) ) ); + + // Sanitize post IDs. + $posts = \array_map( 'absint', (array) $posts ); + $posts = \array_filter( $posts ); + + // Validate send_back URL. + if ( empty( $send_back ) && ! empty( $posts ) ) { + // Try to determine the post type from the first post to preserve context. + $first_post = \get_post( $posts[0] ); + if ( $first_post ) { + $send_back = \admin_url( 'edit.php?post_type=' . $first_post->post_type ); + } else { + $send_back = \admin_url( 'edit.php' ); + } + } elseif ( empty( $send_back ) ) { + $send_back = \admin_url( 'edit.php' ); + } + + // Load template and exit to prevent WordPress from trying to load other admin pages. + \load_template( + ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-delete-confirmation.php', + false, + array( + 'type' => 'posts', + 'items' => $posts, + 'send_back' => $send_back, + ) + ); + exit; + } + + /** + * Handle the bulk post deletion confirmation form submission. + */ + public static function handle_bulk_post_delete_confirmation() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub-bulk-post-delete' ) ) { + \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) ); + } + + // Check permissions. + if ( ! \current_user_can( 'edit_posts' ) ) { + \wp_die( \esc_html__( 'You do not have sufficient permissions to perform this action.', 'activitypub' ) ); + } + + // Get form data. + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $selected_posts = \wp_unslash( $_POST['selected_posts'] ?? array() ); + $send_back = \esc_url_raw( \wp_unslash( $_POST['send_back'] ?? '' ) ); + + // Sanitize post IDs. + $selected_posts = \array_map( 'absint', (array) $selected_posts ); + $selected_posts = \array_filter( $selected_posts ); + + if ( empty( $selected_posts ) ) { + \wp_safe_redirect( $send_back ); + exit; + } + + // Process deletion. + $deleted_count = 0; + foreach ( $selected_posts as $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post ) { + continue; + } + + // Verify the post is still federated. + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED !== $state ) { + continue; + } + + // Check user can edit this post. + if ( ! \current_user_can( 'edit_post', $post_id ) ) { + continue; + } + + // Send Delete activity. + $result = add_to_outbox( $post, 'Delete', $post->post_author ); + if ( $result ) { + // Set visibility to private to prevent re-federation. + \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + ++$deleted_count; + } + } + + // Add success count to redirect URL. + $send_back = \add_query_arg( 'activitypub_deleted', $deleted_count, $send_back ); + + // Redirect back. + \wp_safe_redirect( $send_back ); + exit; + } + + /** + * Handle single post deletion from Fediverse. + */ + public static function handle_single_post_delete() { + // Get and sanitize post ID. + $post_id = \absint( $_GET['post_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification + + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'activitypub-delete-post-' . $post_id ) ) { + \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) ); + } + + // Check post exists. + $post = \get_post( $post_id ); + if ( ! $post ) { + \wp_die( \esc_html__( 'Post not found.', 'activitypub' ) ); + } + + // Check permissions. + if ( ! \current_user_can( 'edit_post', $post_id ) ) { + \wp_die( \esc_html__( 'You do not have sufficient permissions to perform this action.', 'activitypub' ) ); + } + + // Verify the post is federated. + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED !== $state ) { + \wp_die( \esc_html__( 'This post has not been federated.', 'activitypub' ) ); + } + + // Send Delete activity. + $result = add_to_outbox( $post, 'Delete', $post->post_author ); + + // Set visibility to private to prevent re-federation. + if ( $result ) { + \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + } + + // Build redirect URL. + $send_back = \admin_url( 'edit.php' ); + if ( 'post' !== $post->post_type ) { + $send_back = \add_query_arg( 'post_type', $post->post_type, $send_back ); + } + + if ( $result ) { + $send_back = \add_query_arg( 'activitypub_deleted', 1, $send_back ); + } else { + $send_back = \add_query_arg( 'activitypub_delete_failed', 1, $send_back ); + } + + // Redirect back. + \wp_safe_redirect( $send_back ); + exit; + } + /** * Add ActivityPub infos to the dashboard glance items. * @@ -853,24 +1198,51 @@ public static function dashboard_glance_items( $items ) { * @return array The modified actions. */ public static function row_actions( $actions, $post ) { - // check if the post is enabled for ActivityPub. + // Check if the post type supports ActivityPub. if ( ! \post_type_supports( \get_post_type( $post ), 'activitypub' ) || - ! in_array( $post->post_status, array( 'pending', 'draft', 'future', 'publish' ), true ) || - ! \current_user_can( 'edit_post', $post->ID ) || - ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL === get_content_visibility( $post->ID ) || - ( site_supports_blocks() && \use_block_editor_for_post_type( $post->post_type ) ) + ! \current_user_can( 'edit_post', $post->ID ) ) { return $actions; } - $preview_url = add_query_arg( 'activitypub', 'true', \get_preview_post_link( $post ) ); + // Add preview link for non-local posts in block editor. + if ( + in_array( $post->post_status, array( 'pending', 'draft', 'future', 'publish' ), true ) && + ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL !== get_content_visibility( $post->ID ) && + ! ( site_supports_blocks() && \use_block_editor_for_post_type( $post->post_type ) ) + ) { + $preview_url = add_query_arg( 'activitypub', 'true', \get_preview_post_link( $post ) ); - $actions['activitypub'] = sprintf( - '%s', - \esc_url( $preview_url ), - \esc_html__( 'Fediverse Preview ⁂', 'activitypub' ) - ); + $actions['activitypub'] = sprintf( + '%s', + \esc_url( $preview_url ), + \esc_html__( 'Fediverse Preview ⁂', 'activitypub' ) + ); + } + + // Add "Delete from Fediverse" link for federated posts. + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $state ) { + $delete_url = \wp_nonce_url( + \add_query_arg( + array( + 'action' => 'activitypub_delete_post', + 'post_id' => $post->ID, + ), + \admin_url( 'admin-post.php' ) + ), + 'activitypub-delete-post-' . $post->ID + ); + + $actions['activitypub_delete'] = sprintf( + '%s', + \esc_url( $delete_url ), + \esc_attr__( 'Send Delete activity to the Fediverse', 'activitypub' ), + \esc_js( __( 'Are you sure you want to delete this post from the Fediverse?', 'activitypub' ) ), + \esc_html__( 'Soft Delete', 'activitypub' ) + ); + } return $actions; } diff --git a/templates/bulk-actor-delete-confirmation.php b/templates/bulk-actor-delete-confirmation.php deleted file mode 100644 index 918b5c2d52..0000000000 --- a/templates/bulk-actor-delete-confirmation.php +++ /dev/null @@ -1,62 +0,0 @@ - true ) ); -} - -// Prepare user data for display. -$users = get_users( array( 'include' => $users ) ); - -// If no users with ActivityPub capability, redirect back. -if ( ! $users ) { - wp_safe_redirect( $send_back ); - exit; -} - -$GLOBALS['plugin_page'] = ''; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -require_once ABSPATH . 'wp-admin/admin-header.php'; -?> -
-

-

-

This action is irreversible.', 'activitypub' ), array( 'strong' => array() ) ); ?>

-
- - - - - -
-
    - -
  • - -
  • - -
-
- -

- - -

-
-
- 'posts', + 'items' => array(), + 'send_back' => '', + 'checked' => true, + 'cancel_label' => __( 'Cancel', 'activitypub' ), + ) +); + +$item_type = $args['type']; +$item_ids = $args['items']; +$send_back = $args['send_back']; +$checked = $args['checked']; +$cancel_label = $args['cancel_label']; + +// Validate items - redirect back with notice if empty. +if ( empty( $item_ids ) ) { + $notice_param = 'users' === $item_type ? 'activitypub_no_users' : 'activitypub_no_posts'; + wp_safe_redirect( add_query_arg( $notice_param, '1', $send_back ) ); + exit; +} + +// Get items based on type. +$items = array(); +if ( 'users' === $item_type ) { + $items = get_users( array( 'include' => $item_ids ) ); +} else { + foreach ( $item_ids as $item_id ) { + $item = get_post( $item_id ); + if ( $item && current_user_can( 'edit_post', $item_id ) ) { + $items[] = $item; + } + } +} + +// If no valid items, redirect back with notice. +if ( empty( $items ) ) { + $notice_param = 'users' === $item_type ? 'activitypub_no_users' : 'activitypub_no_posts'; + wp_safe_redirect( add_query_arg( $notice_param, '1', $send_back ) ); + exit; +} + +// Set up type-specific variables. +if ( 'users' === $item_type ) { + $page_title = __( 'Delete Users from Fediverse', 'activitypub' ); + $description = __( 'You have removed the capability to publish to the Fediverse for the selected users. Do you also want to send a Delete activity to remove them from the Fediverse?', 'activitypub' ); + $note = __( 'Note: This sends a Delete activity to notify remote servers that these profiles no longer exist.', 'activitypub' ); + $nonce_action = 'bulk-users'; + $form_action = 'delete_actor_confirmed'; + $input_name = 'remove_from_fediverse[]'; + $hidden_name = 'selected_users[]'; + $columns = array( + 'name' => __( 'Name', 'activitypub' ), + ); +} else { + $page_title = __( 'Delete Posts from Fediverse', 'activitypub' ); + $description = __( 'You are about to send Delete activities for the following posts. This will remove them from the Fediverse while keeping them on your site.', 'activitypub' ); + $note = __( 'Note: This sends a Delete activity to notify remote servers. The posts will remain on your WordPress site.', 'activitypub' ); + $nonce_action = 'activitypub-bulk-post-delete'; + $form_action = 'activitypub_delete_posts_confirmed'; + $input_name = 'selected_posts[]'; + $hidden_name = ''; + $columns = array( + 'title' => __( 'Title', 'activitypub' ), + 'author' => __( 'Author', 'activitypub' ), + 'date' => __( 'Date', 'activitypub' ), + ); +} + +$GLOBALS['plugin_page'] = ''; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited +require_once ABSPATH . 'wp-admin/admin-header.php'; +?> +
+

+

+

array() ) ); ?>

+ +
+ + + + + + + + + + $column_label ) : ?> + + + + + + + + + + + + + + + + + + +
+ /> + + +
+ /> + + + + + display_name ); ?> +
+ user_email ); ?> +
+ + + post_author ) ); ?> + + +
+ +

+ + +

+
+
+ +user->create( array( 'role' => 'editor' ) ); + + // Add activitypub capability to the user. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \wp_delete_user( self::$user_id ); + + parent::tear_down_after_class(); + } + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + + \wp_set_current_user( self::$user_id ); + } + + /** + * Tear down. + */ + public function tear_down() { + parent::tear_down(); + + _delete_all_posts(); + } + + /** + * Test post_bulk_options adds the Soft Delete option. + * + * @covers ::post_bulk_options + */ + public function test_post_bulk_options() { + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Move to Trash', + ); + + $result = Admin::post_bulk_options( $actions ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertEquals( 'Soft Delete', $result['activitypub_delete'] ); + // Ensure original actions are preserved. + $this->assertArrayHasKey( 'edit', $result ); + $this->assertArrayHasKey( 'trash', $result ); + } + + /** + * Test handle_post_bulk_request returns early for non-activitypub actions. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_wrong_action() { + $send_back = 'http://example.org/wp-admin/edit.php'; + $post_ids = array( 1, 2, 3 ); + + $result = Admin::handle_post_bulk_request( $send_back, 'trash', $post_ids ); + + $this->assertEquals( $send_back, $result ); + } + + /** + * Test handle_post_bulk_request returns notice URL when no federated posts. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_no_federated_posts() { + // Create a post and mark it as not federated (pending state). + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Explicitly set to pending state (not federated). + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING ); + + $send_back = 'http://example.org/wp-admin/edit.php'; + + $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array( $post_id ) ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test row_actions adds Soft Delete for federated posts. + * + * @covers ::row_actions + */ + public function test_row_actions_adds_soft_delete_for_federated_post() { + // Create a federated post. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Mark as federated. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Trash', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertStringContainsString( 'Soft Delete', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'activitypub_delete_post', $result['activitypub_delete'] ); + } + + /** + * Test row_actions does not add Soft Delete for non-federated posts. + * + * @covers ::row_actions + */ + public function test_row_actions_no_soft_delete_for_non_federated_post() { + // Create a post and mark as pending (not federated). + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Explicitly set to pending state (not federated). + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Trash', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions returns unchanged for unsupported post types. + * + * @covers ::row_actions + */ + public function test_row_actions_unsupported_post_type() { + // Register an unsupported post type. + \register_post_type( 'unsupported_type', array( 'public' => true ) ); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_type' => 'unsupported_type', + 'post_status' => 'publish', + ) + ); + + // Even mark it as federated - shouldn't matter. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + + \unregister_post_type( 'unsupported_type' ); + } + + /** + * Test add_removable_query_args adds the correct args. + * + * @covers ::add_removable_query_args + */ + public function test_add_removable_query_args() { + $args = array( 'existing_arg' ); + + $result = Admin::add_removable_query_args( $args ); + + $this->assertContains( 'existing_arg', $result ); + $this->assertContains( 'activitypub_deleted', $result ); + $this->assertContains( 'activitypub_no_federated', $result ); + $this->assertContains( 'activitypub_no_posts', $result ); + $this->assertContains( 'activitypub_no_users', $result ); + } + + /** + * Test register_post_bulk_actions registers filters for supported post types. + * + * @covers ::register_post_bulk_actions + */ + public function test_register_post_bulk_actions() { + // Clear existing filters. + \remove_all_filters( 'bulk_actions-edit-post' ); + \remove_all_filters( 'handle_bulk_actions-edit-post' ); + + Admin::register_post_bulk_actions(); + + $this->assertTrue( \has_filter( 'bulk_actions-edit-post' ) !== false ); + $this->assertTrue( \has_filter( 'handle_bulk_actions-edit-post' ) !== false ); + } + + /** + * Test row_actions returns unchanged for users without edit capability. + * + * @covers ::row_actions + */ + public function test_row_actions_no_capability() { + // Create a subscriber user. + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + \wp_set_current_user( $subscriber_id ); + + // Create a federated post by another user. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array( + 'view' => 'View', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + + // Restore user. + \wp_set_current_user( self::$user_id ); + \wp_delete_user( $subscriber_id ); + } + + /** + * Test handle_post_bulk_request filters out non-existent posts. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_non_existent_posts() { + $send_back = 'http://example.org/wp-admin/edit.php'; + + // Use non-existent post IDs. + $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array( 999999, 999998 ) ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test handle_post_bulk_request with mixed federated and non-federated posts. + * + * This test verifies that when selecting multiple posts, only federated ones + * are included in the confirmation redirect. Since the method redirects when + * federated posts exist, we test the filtering logic indirectly. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_mixed_posts() { + // Create a non-federated post. + $non_federated_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $non_federated_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING ); + + // Create a deleted post (should not be included). + $deleted_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $deleted_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_DELETED ); + + $send_back = 'http://example.org/wp-admin/edit.php'; + + // With only non-federated and deleted posts, should return notice. + $result = Admin::handle_post_bulk_request( + $send_back, + 'activitypub_delete', + array( $non_federated_id, $deleted_id ) + ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test row_actions does not add Soft Delete for already deleted posts. + * + * @covers ::row_actions + */ + public function test_row_actions_no_soft_delete_for_deleted_post() { + // Create a post marked as already deleted from Fediverse. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Mark as deleted. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_DELETED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Trash', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions does not add Soft Delete for failed posts. + * + * @covers ::row_actions + */ + public function test_row_actions_no_soft_delete_for_failed_post() { + // Create a post marked as failed. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Mark as failed. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FAILED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions includes correct nonce in delete URL. + * + * @covers ::row_actions + */ + public function test_row_actions_delete_url_has_nonce() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertStringContainsString( '_wpnonce', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'post_id=' . $post_id, $result['activitypub_delete'] ); + } + + /** + * Test row_actions includes confirmation dialog. + * + * @covers ::row_actions + */ + public function test_row_actions_delete_has_confirmation() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertStringContainsString( 'onclick', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'confirm', $result['activitypub_delete'] ); + } + + /** + * Test add_removable_query_args includes all ActivityPub query args. + * + * @covers ::add_removable_query_args + */ + public function test_add_removable_query_args_complete() { + $result = Admin::add_removable_query_args( array() ); + + $expected_args = array( + 'activitypub_deleted', + 'activitypub_delete_failed', + 'activitypub_no_federated', + 'activitypub_no_users', + 'activitypub_no_posts', + ); + + foreach ( $expected_args as $arg ) { + $this->assertContains( $arg, $result, "Missing removable query arg: {$arg}" ); + } + } + + /** + * Test register_post_bulk_actions registers for page post type. + * + * @covers ::register_post_bulk_actions + */ + public function test_register_post_bulk_actions_pages() { + // Ensure page post type supports activitypub. + \add_post_type_support( 'page', 'activitypub' ); + + // Clear existing filters. + \remove_all_filters( 'bulk_actions-edit-page' ); + \remove_all_filters( 'handle_bulk_actions-edit-page' ); + + Admin::register_post_bulk_actions(); + + $this->assertTrue( \has_filter( 'bulk_actions-edit-page' ) !== false ); + $this->assertTrue( \has_filter( 'handle_bulk_actions-edit-page' ) !== false ); + } + + /** + * Test post_bulk_options preserves action order. + * + * @covers ::post_bulk_options + */ + public function test_post_bulk_options_preserves_order() { + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Move to Trash', + ); + + $result = Admin::post_bulk_options( $actions ); + + $keys = array_keys( $result ); + + // Original actions should come first. + $this->assertEquals( 'edit', $keys[0] ); + $this->assertEquals( 'trash', $keys[1] ); + // ActivityPub action added at end. + $this->assertEquals( 'activitypub_delete', $keys[2] ); + } + + /** + * Test handle_post_bulk_request with empty post array. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_empty_array() { + $send_back = 'http://example.org/wp-admin/edit.php'; + + $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array() ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test row_actions works with page post type. + * + * @covers ::row_actions + */ + public function test_row_actions_with_page() { + // Ensure page post type supports activitypub. + \add_post_type_support( 'page', 'activitypub' ); + + $page_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + \update_post_meta( $page_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $page = \get_post( $page_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $page ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertStringContainsString( 'Soft Delete', $result['activitypub_delete'] ); + } + + /** + * Test row_actions with draft post does not add Soft Delete even if federated. + * + * Draft posts shouldn't normally be federated, but if somehow they are, + * the soft delete action should still be available since the state is federated. + * + * @covers ::row_actions + */ + public function test_row_actions_federated_draft() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'draft', + ) + ); + // Hypothetically federated draft. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + // Should still show soft delete since it's marked as federated. + $this->assertArrayHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions delete link has proper title attribute. + * + * @covers ::row_actions + */ + public function test_row_actions_delete_has_title() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertStringContainsString( 'title=', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'Send Delete activity', $result['activitypub_delete'] ); + } +}