diff --git a/abilities/class-ability-purge-records.php b/abilities/class-ability-purge-records.php index 4c8e149d2..4f9735f49 100644 --- a/abilities/class-ability-purge-records.php +++ b/abilities/class-ability-purge-records.php @@ -123,7 +123,7 @@ public function execute( $input = null ) { // Stream stores `created` in UTC (Log::log() writes current_time('mysql', true)). // MySQL's NOW() uses the server timezone, so comparing against it can // delete too many or too few rows on hosts where the server is not UTC. - // Mirror Admin::purge_scheduled_action(): compute a UTC cutoff in PHP + // Mirror Admin_Purge::purge_scheduled_action(): compute a UTC cutoff in PHP // and bind it as a string. $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) ->sub( \DateInterval::createFromDateString( ( (int) $input['older_than_days'] ) . ' days' ) ) @@ -184,7 +184,7 @@ public function execute( $input = null ) { } // Delete matching stream rows AND their meta in a single multi-table DELETE, - // mirroring Admin::purge_scheduled_action(). Doing both sides in one statement + // mirroring Admin_Purge::purge_scheduled_action(). Doing both sides in one statement // avoids a follow-up full-table scan over $wpdb->streammeta to clean up // orphans, which on busy sites could lock the meta table for a long time. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery diff --git a/classes/class-admin-ajax.php b/classes/class-admin-ajax.php new file mode 100644 index 000000000..fb561888d --- /dev/null +++ b/classes/class-admin-ajax.php @@ -0,0 +1,244 @@ +register_hooks(); + } + + /** + * Register WordPress actions and filters for admin Ajax handlers. + */ + private function register_hooks(): void { + add_action( 'wp_ajax_wp_stream_reset', array( $this, 'wp_ajax_reset' ) ); + add_action( 'wp_ajax_wp_stream_clean_orphan_meta', array( $this, 'wp_ajax_clean_orphan_meta' ) ); + add_action( 'wp_ajax_wp_stream_filters', array( $this, 'ajax_filters' ) ); + add_action( 'admin_notices', array( $this, 'maybe_display_message' ) ); + add_action( 'network_admin_notices', array( $this, 'maybe_display_message' ) ); + } + + /** + * Handle the reset AJAX request to reset logs. + * + * @return bool + */ + public function wp_ajax_reset() { + check_ajax_referer( 'stream_nonce_reset', 'wp_stream_nonce_reset' ); + + if ( ! current_user_can( $this->admin->settings_cap ) ) { + wp_die( + esc_html__( "You don't have sufficient privileges to do this action.", 'stream' ) + ); + } + + // Ensure the database tables exist before attempting to clear records. + // Install::check() short-circuits on DOING_AJAX, so call install() + // directly. dbDelta is idempotent and safe to run when tables already + // exist. + $this->admin->plugin->install->install( $this->admin->plugin->get_version() ); + + $this->purge->erase_stream_records(); + + if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) { + return true; + } + + wp_safe_redirect( + add_query_arg( + array( + 'page' => is_network_admin() ? $this->admin->network->network_settings_page_slug : $this->admin->settings_page_slug, + 'message' => 'data_erased', + ), + self_admin_url( $this->admin->admin_parent_page ) + ) + ); + + exit; + } + + /** + * Ajax handler for the "Clean orphaned meta now" button on + * Settings → Advanced. + * + * Schedules an immediate async run of the orphan reaper. Idempotent: + * if a reaper is already scheduled, returns without enqueuing a second. + * + * Returns true under WP_STREAM_TESTS so PHPUnit can call this directly + * without exiting the worker. + * + * @return bool|void True under tests; otherwise redirects and exits. + */ + public function wp_ajax_clean_orphan_meta() { + if ( ! current_user_can( $this->admin->settings_cap ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'stream' ), 403 ); + } + + check_ajax_referer( 'stream_nonce_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' ); + + if ( empty( $this->admin->plugin->scheduler ) ) { + wp_die( esc_html__( 'No scheduler is available.', 'stream' ), 500 ); + } + + // Idempotency: skip enqueue when any auto-purge action is already + // pending or running. is_running_auto_purge() checks PENDING + RUNNING + // across the batch worker and the reaper, so a chain that will run its + // own terminal reaper is not duplicated by a manual click landing in + // the small CSRF/stale-URL window where the UI link is hidden. + if ( ! $this->purge->is_running_auto_purge() ) { + $this->admin->plugin->scheduler->enqueue_async( Admin::AUTO_PURGE_REAPER_ACTION, array(), Admin::AUTO_PURGE_GROUP ); + } + + if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) { + return true; + } + + $is_network = $this->admin->plugin->is_multisite_network_activated(); + $page_slug = $is_network ? $this->admin->network->network_settings_page_slug : $this->admin->settings_page_slug; + $base_url = $is_network ? network_admin_url( $this->admin->admin_parent_page ) : admin_url( $this->admin->admin_parent_page ); + + wp_safe_redirect( + add_query_arg( + array( + 'page' => $page_slug, + 'wp_stream_message' => 'orphan_meta_cleanup_scheduled', + ), + $base_url + ) + ); + exit; + } + + /** + * Ajax callback for return a user list. + * + * @action wp_ajax_wp_stream_filters + */ + public function ajax_filters() { + if ( ! defined( 'DOING_AJAX' ) || ! current_user_can( $this->admin->settings_cap ) ) { + wp_die( '-1' ); + } + + check_ajax_referer( 'stream_filters_user_search_nonce', 'nonce' ); + + switch ( wp_stream_filter_input( INPUT_GET, 'filter' ) ) { + case 'user_id': + $users = array_merge( + array( + 0 => (object) array( + 'display_name' => 'WP-CLI', + ), + ), + get_users() + ); + + $search = wp_stream_filter_input( INPUT_GET, 'q' ); + if ( is_string( $search ) && '' !== $search ) { + // `search` arg for get_users() is not enough. + $filtered = array(); + foreach ( $users as $key => $user ) { + if ( self::user_display_name_contains( $user, $search ) ) { + $filtered[ $key ] = $user; + } + } + $users = $filtered; + } + + if ( count( $users ) > $this->admin->preload_users_max ) { + $users = array_slice( $users, 0, $this->admin->preload_users_max ); + } + + // Get gravatar / roles for final result set. + $results = $this->get_users_record_meta( $users ); + + break; + } + + if ( isset( $results ) ) { + echo wp_json_encode( $results ); + } + + die(); + } + + /** + * Return relevant user meta data for Ajax filter results. + * + * @param array $authors Author data keyed by user ID. + * @return array + */ + public function get_users_record_meta( $authors ) { + $authors_records = array(); + + foreach ( $authors as $user_id => $args ) { + $author = new Author( $args->ID ); + + $authors_records[ $user_id ] = array( + 'text' => $author->get_display_name(), + 'id' => $author->id, + 'label' => $author->get_display_name(), + 'icon' => $author->get_avatar_src( 32 ), + 'title' => '', + ); + } + + return $authors_records; + } + + /** + * Render confirmation notices keyed by the wp_stream_message query arg. + * + * @action admin_notices + * @action network_admin_notices + * + * @return void + */ + public function maybe_display_message() { + $message = wp_stream_filter_input( INPUT_GET, 'wp_stream_message' ); + if ( empty( $message ) ) { + return; + } + + $notices = array( + 'orphan_meta_cleanup_scheduled' => __( + 'Orphaned meta cleanup scheduled. Progress is visible under Tools → Scheduled Actions.', + 'stream' + ), + ); + + if ( ! isset( $notices[ $message ] ) ) { + return; + } + + printf( + '

%s

', + esc_html( $notices[ $message ] ) + ); + } + + /** + * Whether a user display name contains the search needle. + * + * @param object $user User-like object with display_name. + * @param string $search Search needle. + * @return bool + */ + private static function user_display_name_contains( $user, string $search ): bool { + return false !== mb_strpos( mb_strtolower( $user->display_name ), mb_strtolower( $search ) ); + } +} diff --git a/classes/class-admin-assets.php b/classes/class-admin-assets.php new file mode 100644 index 000000000..b1ca5f2c6 --- /dev/null +++ b/classes/class-admin-assets.php @@ -0,0 +1,212 @@ +register_hooks(); + } + + /** + * Register WordPress actions and filters for admin assets. + */ + private function register_hooks(): void { + add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'admin_menu_css' ) ); + } + + /** + * Enqueue scripts/styles for admin screen + * + * @action admin_enqueue_scripts + * + * @param string $hook Current hook. + * + * @return void + */ + public function admin_enqueue_scripts( $hook ) { + if ( in_array( $hook, $this->admin->menu->screen_id, true ) ) { + $this->admin->plugin->enqueue_asset( + 'admin', + array( + $this->admin->plugin->with_select2(), + $this->admin->plugin->with_jquery_timeago(), + ), + array( + 'i18n' => array( + 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ), + 'confirm_defaults' => __( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ), + ), + 'locale' => strtolower( substr( get_locale(), 0, 2 ) ), + 'gmt_offset' => get_option( 'gmt_offset' ), + ) + ); + + $this->admin->plugin->enqueue_asset( + 'admin-exclude', + array( + $this->admin->plugin->with_select2(), + ), + array( + 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ), + ) + ); + + $current_order = isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! in_array( $current_order, array( 'asc', 'desc' ), true ) ) { + $current_order = 'desc'; + } + $current_query = map_deep( wp_unslash( $_GET ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + $this->admin->plugin->enqueue_asset( + 'live-updates', + array( 'heartbeat' ), + array( + 'current_screen' => $hook, + 'current_page' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : '1', // phpcs:ignore WordPress.Security.NonceVerification.Recommended + 'current_order' => $current_order, + 'current_query' => wp_json_encode( $current_query ), + 'current_query_count' => count( $current_query ), + ) + ); + } + + /** + * The maximum number of items that can be updated in bulk without receiving a warning. + * + * Stream watches for bulk actions performed in the WordPress Admin (such as updating + * many posts at once) and warns the user before proceeding if the number of items they + * are attempting to update exceeds this threshold value. Since Stream will try to save + * a log for each item, it will take longer than usual to complete the operation. + * + * The default threshold is 100 items. + * + * @return int + */ + $bulk_actions_threshold = apply_filters( 'wp_stream_bulk_actions_threshold', 100 ); + + $this->admin->plugin->enqueue_asset( + 'global', + array(), + array( + 'bulk_actions' => array( + 'i18n' => array( + /* translators: %s: a number of items (e.g. "1,742") */ + 'confirm_action' => sprintf( __( 'Are you sure you want to perform bulk actions on over %s items? This process could take a while to complete.', 'stream' ), number_format( absint( $bulk_actions_threshold ) ) ), + ), + 'threshold' => absint( $bulk_actions_threshold ), + ), + 'plugins_screen_url' => self_admin_url( 'plugins.php#stream' ), + ) + ); + } + + /** + * Check whether or not the current admin screen belongs to Stream + * + * @return bool + */ + public function is_stream_screen() { + if ( ! is_admin() ) { + return false; + } + + $page = wp_stream_filter_input( INPUT_GET, 'page' ); + if ( is_string( $page ) && false !== strpos( $page, $this->admin->records_page_slug ) ) { + return true; + } + + if ( is_admin() && function_exists( 'get_current_screen' ) ) { + $screen = get_current_screen(); + + return ( Alerts::POST_TYPE === $screen->post_type ); + } + + return false; + } + + /** + * Add a specific body class to all Stream admin screens + * + * @param string $classes CSS classes to output to body. + * + * @filter admin_body_class + * + * @return string + */ + public function admin_body_class( $classes ) { + $stream_classes = array(); + + if ( $this->is_stream_screen() ) { + $stream_classes[] = $this->admin->admin_body_class; + + if ( isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $stream_classes[] = sanitize_key( $_GET['page'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + } + + /** + * Filter the Stream admin body classes + * + * @return array + */ + $stream_classes = apply_filters( 'wp_stream_admin_body_classes', $stream_classes ); + $stream_classes = implode( ' ', array_map( 'trim', $stream_classes ) ); + + return sprintf( '%s %s ', $classes, $stream_classes ); + } + + /** + * Add menu styles for various WP Admin skins. + * + * @action admin_enqueue_scripts + */ + public function admin_menu_css() { + // Make sure we're working off a clean version. + if ( ! file_exists( ABSPATH . WPINC . '/version.php' ) ) { + return; + } + include ABSPATH . WPINC . '/version.php'; + + if ( ! isset( $wp_version ) ) { + return; + } + + $css = " + body.{$this->admin->admin_body_class} #wpbody-content .wrap h1:nth-child(1):before { + content: ''; + display: inline-block; + width: 24px; + height: 24px; + margin-right: 8px; + vertical-align: text-bottom; + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9ImN1cnJlbnRjb2xvciI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo='); + } + #menu-posts-feedback .wp-menu-image:before { + font-family: dashicons !important; + content: '\\f175'; + } + #adminmenu #menu-posts-feedback div.wp-menu-image { + background: none !important; + background-repeat: no-repeat; + } + "; + + wp_add_inline_style( 'wp-admin', $css ); + } +} diff --git a/classes/class-admin-menu.php b/classes/class-admin-menu.php new file mode 100644 index 000000000..d82ecb4d4 --- /dev/null +++ b/classes/class-admin-menu.php @@ -0,0 +1,144 @@ + + */ + public array $screen_id = array(); + + /** + * Class constructor. + * + * @param Admin $admin Admin façade. + */ + public function __construct( private Admin $admin ) { + $this->register_hooks(); + } + + /** + * Register WordPress actions for the admin menu. + * + * @return void + */ + private function register_hooks(): void { + if ( ! $this->is_site_access_disabled() ) { + add_action( 'admin_menu', array( $this, 'register_menu' ) ); + } + } + + /** + * Whether site-level Stream admin is disabled by network settings. + * + * On network-activated multisite, the network admin can turn off per-site + * access; only the network admin UI remains available when disabled. + * + * @return bool + */ + private function is_site_access_disabled(): bool { + if ( ! $this->admin->plugin->is_multisite_network_activated() || is_network_admin() ) { + return false; + } + + $options = (array) get_site_option( 'wp_stream_network', array() ); + $option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1; + + return ! $option; + } + + /** + * Register menu page + * + * @action admin_menu + * + * @return void + */ + public function register_menu() { + /** + * Filter the main admin menu title + * + * @return string + */ + $main_menu_title = apply_filters( 'wp_stream_admin_menu_title', esc_html__( 'Stream', 'stream' ) ); + + /** + * Filter the main admin menu position + * + * Note: Using longtail decimal string to reduce the chance of position conflicts, see Codex + * + * @return string + */ + $main_menu_position = apply_filters( 'wp_stream_menu_position', '2.999999' ); + + /** + * Filter the main admin page title + * + * @return string + */ + $main_page_title = apply_filters( 'wp_stream_admin_page_title', esc_html__( 'Stream Records', 'stream' ) ); + + $this->screen_id['main'] = add_menu_page( + $main_page_title, + $main_menu_title, + $this->admin->view_cap, + $this->admin->records_page_slug, + array( $this->admin->records, 'render_list_table' ), + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IjAwMCI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=', + $main_menu_position + ); + + /** + * Fires before submenu items are added to the Stream menu + * allowing plugins to add menu items before Settings + * + * @return void + */ + do_action( 'wp_stream_admin_menu' ); + + /** + * Filter the Settings admin page title + * + * @return string + */ + $settings_page_title = apply_filters( 'wp_stream_settings_form_title', esc_html__( 'Stream Settings', 'stream' ) ); + + $this->screen_id['settings'] = add_submenu_page( + $this->admin->records_page_slug, + $settings_page_title, + esc_html__( 'Settings', 'stream' ), + $this->admin->settings_cap, + $this->admin->settings_page_slug, + array( $this->admin->settings, 'render_settings_page' ) + ); + + if ( isset( $this->screen_id['main'] ) ) { + /** + * Fires just before the Stream list table is registered. + * + * @return void + */ + do_action( 'wp_stream_admin_menu_screens' ); + + // Register the list table early, so it associates the column headers with 'Screen settings'. + add_action( + 'load-' . $this->screen_id['main'], + array( + $this->admin->records, + 'register_list_table', + ) + ); + } + } +} diff --git a/classes/class-admin-purge.php b/classes/class-admin-purge.php new file mode 100644 index 000000000..d6d6485e5 --- /dev/null +++ b/classes/class-admin-purge.php @@ -0,0 +1,825 @@ +register_hooks(); + } + + /** + * Register WordPress actions and filters for purge / cron / async erase. + */ + private function register_hooks(): void { + add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) ); + add_action( Admin::AUTO_PURGE_ACTION, array( $this, 'purge_scheduled_action' ) ); + add_action( Admin::AUTO_PURGE_BATCH_ACTION, array( $this, 'auto_purge_batch' ), 10, 3 ); + add_action( Admin::AUTO_PURGE_REAPER_ACTION, array( $this, 'auto_purge_reaper' ) ); + add_action( Admin::ASYNC_DELETION_ACTION, array( $this, 'erase_large_records' ), 10, 4 ); + add_action( 'admin_notices', array( $this, 'display_large_table_cron_notice' ) ); + add_action( 'network_admin_notices', array( $this, 'display_large_table_cron_notice' ) ); + } + + /** + * Checks if the async deletion process is running. + * + * Checks pending AND in-flight state, mirroring + * {@see Admin_Purge::is_running_auto_purge()}. Under WP-Cron the event is + * removed from the cron array before its callback runs, so a + * pending-only probe would momentarily read idle mid-chain and briefly + * re-expose the reset link in Settings. The batch worker keeps the + * best-effort running marker set for that window (see + * {@see Admin_Purge::erase_large_records()}). The marker transient is shared + * with the auto-purge chain, which only makes both guards more + * conservative — never less safe. + * + * @return bool True if the async deletion process is running, false otherwise. + */ + public function is_running_async_deletion() { + $scheduler = $this->admin->plugin->scheduler; + if ( empty( $scheduler ) ) { + return false; + } + return $scheduler->any_pending_or_running( array( Admin::ASYNC_DELETION_ACTION ) ); + } + + /** + * Checks if any auto-purge action is currently scheduled or in-flight. + * + * Returns true when either the batched chain worker or the terminal + * orphan reaper is pending OR running. The recurring scheduler is + * intentionally excluded — it is always pending under normal operation, + * so including it here would make the probe useless. Used by the + * Settings → Advanced UI to render an "Auto-purge currently running" + * notice and by the recurring callback as an overlap guard. + * + * Checks both PENDING and IN-PROGRESS statuses so a chain that is + * mid-execution (e.g. the batch worker is currently running and has not + * yet enqueued the next batch) still reports as running. Without the + * RUNNING check the overlap guard can let a second parallel chain stack + * against the same rows. + * + * @return bool + */ + public function is_running_auto_purge() { + $scheduler = $this->admin->plugin->scheduler; + if ( empty( $scheduler ) ) { + return false; + } + + return $scheduler->any_pending_or_running( + array( Admin::AUTO_PURGE_BATCH_ACTION, Admin::AUTO_PURGE_REAPER_ACTION ) + ); + } + + /** + * Clears stream records from the database. + * + * @return void + */ + public function erase_stream_records() { + global $wpdb; + + // If this is a multisite and it's not network activated, + // only delete the entries from the blog which made the request. + if ( $this->admin->plugin->is_multisite_not_network_activated() ) { + + // First check the log size. + $stream_log_size = $this->admin->get_blog_record_table_size(); + + // If this is a large log and we need to delete only the entries + // pertaining to an individual site, we will need to do those in batches. + if ( $this->admin->plugin->is_large_records_table( $stream_log_size ) ) { + $this->schedule_erase_large_records( $stream_log_size ); + return; + } + + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `blog_id`=%d;", + get_current_blog_id() + ) + ); + } else { + // If we are deleting all the entries, we can truncate the tables. + $wpdb->query( "TRUNCATE {$wpdb->streammeta};" ); + $wpdb->query( "TRUNCATE {$wpdb->stream};" ); + // Tidy up any meta which may have been added in between the two truncations. + $this->delete_orphaned_meta(); + } + } + + /** + * Schedule the initial event to start erasing the logs from now. + * + * @param int $log_size The number of rows which will be affected. + * @return void + */ + public function schedule_erase_large_records( int $log_size ) { + global $wpdb; + + $last_entry = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `blog_id`=%d ORDER BY ID DESC LIMIT 1", + get_current_blog_id() + ) + ); + + // If there are no entries to erase, don't try to erase them. + if ( empty( $last_entry ) ) { + return; + } + + // We are going to delete this many and this many only. + // This is to avoid the situation where rows keep getting added + // between the Action Scheduler runs and they never stop. + $args = array( + 'total' => (int) $log_size, + 'done' => 0, + 'last_entry' => (int) $last_entry, + 'blog_id' => (int) get_current_blog_id(), + ); + + $this->admin->plugin->scheduler->enqueue_async( Admin::ASYNC_DELETION_ACTION, $args ); + + $this->maybe_warn_large_table_without_action_scheduler( + (int) $log_size, + __( 'reset the Stream database (delete all records for this site)', 'stream' ) + ); + } + + /** + * Warn when a large-table batched operation has to lean on WP-Cron. + * + * Action Scheduler is purpose-built to drain long self-chaining batch + * jobs reliably; default WP-Cron fires opportunistically on traffic and + * can stall a multi-hour chain on a low-traffic site. When Stream is + * running the WP-Cron fallback (the `wp_stream_use_action_scheduler` + * filter returned false, or the bundled AS library is absent) against a + * table over the large-table threshold, surface a notice pointing the + * operator at a deterministic WP-CLI drain instead of failing silently. + * + * Delivery depends on context. Under WP-CLI the warning is emitted + * immediately via {@see Admin::notice()} (WP_CLI::warning) — scheduling + * the batch chain onto WP-Cron does not drain it, so a headless / + * low-traffic site is exactly where the chain can stall. Outside WP-CLI + * neither call site renders its own output (the recurring purge runs + * under DOING_CRON; the manual reset redirects and exits before its + * shutdown hook output reaches the browser), so the message is persisted + * to {@see Admin::LARGE_TABLE_CRON_NOTICE_OPTION} and rendered on the + * next admin page load by {@see Admin_Purge::display_large_table_cron_notice()}. + * + * No-op when Action Scheduler is the active backend (built to drain long + * chains). The `wp_stream_enable_auto_purge` filter deliberately does NOT + * gate this helper: it governs TTL retention purging only, while this + * warning also covers the manual database reset — an operator who manages + * retention externally can still click "Reset Stream Database" and needs + * the stall warning. The auto-purge call site is already gated by the + * filter's early return in {@see Admin_Purge::purge_scheduled_action()}. + * + * @param int $record_count Number of rows the operation will touch. + * @param string $operation Human-readable, translated description of what the + * batched work does (e.g. "delete records older than + * the retention period"), interpolated into the notice. + * @return void + */ + public function maybe_warn_large_table_without_action_scheduler( int $record_count, string $operation ) { + if ( $this->admin->plugin->scheduler instanceof AS_Scheduler ) { + return; + } + + if ( ! $this->admin->plugin->is_large_records_table( $record_count ) ) { + return; + } + + $message = sprintf( + /* translators: 1: operation description (e.g. "delete records older than the retention period"), 2: number of records, 3: WP-CLI command. */ + __( 'Stream queued a large batched operation to %1$s (%2$s records) to WP-Cron because Action Scheduler is disabled. The records are removed in chained batches as WP-Cron runs. This completes on its own where reliable cron is configured (a Linux crontab or third-party cron service triggering wp-cron.php on a fixed interval, without an execution timeout). On sites relying on default traffic-triggered WP-Cron the chain may stall before it finishes, leaving records only partly removed; to run it to completion deterministically, use WP-CLI: %3$s', 'stream' ), + $operation, + number_format_i18n( $record_count ), + 'wp cron event run --due-now' + ); + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + // Immediate WP_CLI::warning — the operator is watching the terminal. + $this->admin->notice( $message ); + return; + } + + // Persist for the next admin page load. Neither call site can render + // output itself: the recurring purge runs under DOING_CRON (response + // discarded) and the manual reset redirects + exits before shutdown + // output reaches the browser. No autoload — this is set rarely and + // read only in the admin. + update_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION, $message, false ); + } + + /** + * Render (and clear) the persisted large-table WP-Cron warning. + * + * Counterpart to {@see Admin_Purge::maybe_warn_large_table_without_action_scheduler()}: + * displays the stored warning on the first admin page an operator with + * the Stream settings capability loads after a large batched operation + * was queued onto WP-Cron. + * + * @action admin_notices + * @action network_admin_notices + * + * @return void + */ + public function display_large_table_cron_notice() { + if ( ! current_user_can( $this->admin->settings_cap ) ) { + return; + } + + $message = get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); + if ( empty( $message ) ) { + return; + } + + delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); + + printf( + '
%s
', + wp_kses_post( wpautop( $message ) ) + ); + } + + /** + * Erases large records from the stream table. + * + * This function deletes records from the stream table in batches, starting from a given entry ID. + * It deletes records in reverse chronological order, starting from the largest ID and going back. + * The number of records deleted in each batch is determined by the batch size, which can be filtered + * using the 'wp_stream_batch_size' hook. + * + * @param int $total The total number of records to be deleted. + * @param int $done The number of records that have already been deleted. + * @param int $last_entry The ID of the last entry that was deleted. + * @param int $blog_id The ID of the blog for which the records should be deleted. + * @return void + */ + public function erase_large_records( int $total, int $done, int $last_entry, int $blog_id ) { + global $wpdb; + + // Best-effort "running" marker, mirroring auto_purge_batch(). Under + // WP-Cron the event is dequeued before this callback runs, so without + // the marker is_running_async_deletion() would momentarily read idle + // between batches and briefly re-expose the reset link in Settings. + // No-op under Action Scheduler; self-expires on a fatal. + $this->admin->plugin->scheduler->mark_running( 'async_deletion' ); + + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE ID < %d AND `blog_id`=%d ORDER BY ID DESC LIMIT 1", + $last_entry + 1, // A tweak to get it correct the first time through. + get_current_blog_id() + ) + ); + + if ( empty( $start_from ) ) { + // Terminal batch: nothing left to delete, no further event will + // be chained, and no work follows within this callback — safe to + // clear the marker immediately (unlike the auto-purge chain, + // whose terminal batch hands off to the reaper). + $this->admin->plugin->scheduler->mark_done( 'async_deletion' ); + return; + } + + /** + * Filters the number of records in the {$wpdb->stream} table to do at a time. + * + * @since 4.1.0 + * + * @param int $batch_size The batch size, default 250000. + */ + $batch_size = apply_filters( 'wp_stream_batch_size', 250000 ); + + // This will tend to erase them in reverse chronological order, + // ie it will start from the largest ID and go back from there. + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE ID <= %d AND ID >= %d AND `blog_id`=%d;", + $start_from, + $start_from - $batch_size, + get_current_blog_id() + ) + ); + + $remaining = $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d", $blog_id ) + ); + + $done = $total - $remaining; + + $this->admin->plugin->scheduler->enqueue_async( + Admin::ASYNC_DELETION_ACTION, + array( + 'total' => (int) $total, + 'done' => (int) $done, + 'last_entry' => (int) $start_from - $batch_size, // The last ID checked. + 'blog_id' => (int) $blog_id, + ) + ); + } + + /** + * Schedules a purge of records. + * + * @return void + */ + public function purge_schedule_setup() { + // Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it + // cannot double-fire alongside the new recurring action. + if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) { + wp_clear_scheduled_hook( 'wp_stream_auto_purge' ); + } + + $scheduler = $this->admin->plugin->scheduler; + + /** + * Filter whether Stream schedules its TTL record auto-purge at all. + * + * Custom storage drivers that manage retention externally (TTL + * indexes, partition rotation, a warehouse job, etc.) can return + * false to disable all TTL purge scheduling regardless of the + * scheduler backend. Any already-registered recurring purge is + * unscheduled from both backends so it cannot keep firing. + * + * @param bool $enabled Whether auto-purge scheduling is enabled. + */ + if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) { + // Tear down only once, then record the 'disabled' sentinel in the + // backend marker. This runs on every wp_loaded, so without the + // guard a permanently-disabled site would pay the unschedule + // probes on every request; with it, steady state is a single + // in-memory compare (the marker is autoloaded). The sentinel also + // covers a site upgrading with the filter already active (no + // marker yet, but a recurring action left by a previous version). + // The executing path is independently gated by the same filter in + // purge_scheduled_action(), so a stray entry that somehow survives + // cannot purge anything anyway. + if ( 'disabled' !== get_option( Admin::SCHEDULER_BACKEND_OPTION ) ) { + $scheduler->unschedule_all( Admin::AUTO_PURGE_ACTION ); + wp_unschedule_hook( Admin::AUTO_PURGE_ACTION ); + + // Also clear the Action Scheduler store when its API is + // available but AS is not the active backend (e.g. the cron + // backend is selected while WooCommerce provides AS). The + // active-backend unschedule above cannot see AS's store, and + // this filter promises teardown from BOTH backends. When AS + // is entirely absent this is skipped — a stray AS entry + // cannot execute (no AS runner), and if AS appears later the + // action fires as a no-op thanks to the execute-path gate. + if ( ! $scheduler instanceof AS_Scheduler && function_exists( 'as_unschedule_all_actions' ) ) { + ( new AS_Scheduler() )->unschedule_all( Admin::AUTO_PURGE_ACTION ); + } + + update_option( Admin::SCHEDULER_BACKEND_OPTION, 'disabled' ); + } + return; + } + + $backend = $scheduler instanceof AS_Scheduler ? 'action_scheduler' : 'wp_cron'; + + // Detect a backend switch and clear the inactive backend's copy of the + // recurring action exactly once. A site that switched schedulers (via + // the wp_stream_use_action_scheduler filter) would otherwise keep + // firing the purge from BOTH backends — the two stores are independent + // and neither overlap guard can see the other. The marker is an + // autoloaded option, so the steady-state cost on every wp_loaded is a + // single in-memory compare; the cleanup query runs only on the first + // page load after a switch. Idempotent and self-healing. No data is + // affected — only the redundant schedule entry. + if ( get_option( Admin::SCHEDULER_BACKEND_OPTION ) !== $backend ) { + $cleanup_done = true; + + if ( 'action_scheduler' === $backend ) { + // Drop any leftover WP-Cron recurring event. + wp_unschedule_hook( Admin::AUTO_PURGE_ACTION ); + } elseif ( function_exists( 'as_unschedule_all_actions' ) ) { + // Drop any leftover Action Scheduler recurring action. Routed + // through AS_Scheduler so the as_*() call stays contained there. + ( new AS_Scheduler() )->unschedule_all( Admin::AUTO_PURGE_ACTION ); + } else { + // Action Scheduler is not loaded (cron backend selected and no + // other plugin provides AS), so its store cannot be cleaned + // right now. Do NOT write the marker: if an AS-providing + // plugin (e.g. WooCommerce) is installed later, the stray + // Stream recurring action in the AS store would resume firing + // alongside the cron one — and the cron overlap guard cannot + // see it. Leaving the marker stale retries this cleanup on a + // later request once as_unschedule_all_actions() exists. + $cleanup_done = false; + } + + if ( $cleanup_done ) { + update_option( Admin::SCHEDULER_BACKEND_OPTION, $backend ); + } + } + + // 12 hours == old `twicedaily` interval. The scheduler only schedules + // a fresh recurring action when one is not already registered. + $scheduler->schedule_recurring( + time(), + 12 * HOUR_IN_SECONDS, + Admin::AUTO_PURGE_ACTION, + array(), + Admin::AUTO_PURGE_GROUP + ); + } + + /** + * Deletes orphaned meta records from the database. + * + * Deletes meta records from the stream meta table where the corresponding + * stream record no longer exists. + * + * @global wpdb $wpdb The WordPress database object. + */ + public function delete_orphaned_meta() { + global $wpdb; + + $wpdb->query( + "DELETE `meta` FROM {$wpdb->streammeta} as `meta` LEFT JOIN {$wpdb->stream} as `stream` ON `stream`.`ID`=`meta`.`record_id` WHERE `stream`.`ID` IS NULL" + ); + } + + /** + * Executes a scheduled purge + * + * @return void + */ + public function purge_scheduled_action() { + // Respect the auto-purge master switch on the executing path too, not + // just at scheduling time. A recurring action already in flight when + // the filter flips to false (or an args-specific entry the unschedule + // missed) would otherwise still run a purge cycle the operator opted + // out of. This filter is documented in Admin::purge_schedule_setup(). + if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) { + return; + } + + // Don't purge when in Network Admin unless Stream is network activated. + if ( + $this->admin->plugin->is_multisite_not_network_activated() + && + is_network_admin() + ) { + return; + } + + $defaults = $this->admin->plugin->settings->get_defaults(); + if ( $this->admin->plugin->is_multisite_network_activated() ) { + $options = wp_parse_args( (array) get_site_option( 'wp_stream_network', array() ), $defaults ); + } else { + $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults ); + } + + // TTL fallback. Settings::get_defaults() runs every settings field + // through the `wp_stream_settings_option_fields` filter, which + // Network::get_network_admin_fields() uses to strip the `records_ttl` + // field from the per-site option's defaults set. When this callback runs + // outside any admin context (Action Scheduler, WP-CLI, system cron), the + // per-site option_key is in effect, so the filtered defaults array does + // not contain general_records_ttl at all. Apply the documented 30-day + // default (classes/class-settings.php, `records_ttl` field) only when + // the key is genuinely missing, so an operator who set the value via + // CLI/SQL keeps their explicit choice. + if ( ! isset( $options['general_records_ttl'] ) ) { + $options['general_records_ttl'] = 30; + } + + if ( ! empty( $options['general_keep_records_indefinitely'] ) ) { + return; + } + + // Refuse to purge with a non-positive TTL. The UI enforces min=1, but + // CLI/SQL can set 0 or a negative integer. Honoring those would mean + // "delete every record on every cycle", which has no legitimate use + // case (keep_records_indefinitely covers the opposite extreme). + // Bailing out makes operator error visible (records stop being purged) + // instead of catastrophic (records get wiped repeatedly). + if ( (int) $options['general_records_ttl'] < 1 ) { + return; + } + + // Overlap guard: if any auto-purge action (batch worker or reaper) is + // pending or in-progress, don't stack a new chain. Reuses the same + // probe used by the Settings UI so the two views of "running" agree. + if ( $this->is_running_auto_purge() ) { + return; + } + + /** + * Fires once per auto-purge cycle, after all bail-out checks pass and + * immediately before deletion work is enqueued. + * + * Preserved for backward compatibility with consumers that hooked the + * legacy WP-Cron event of the same name in Stream <= 4.1.x. Note that + * since 4.2.0 this fires only when a purge is actually about to run — + * it no longer fires on every cron tick regardless of whether work + * happens. Hook into the recurring AS action (Admin::AUTO_PURGE_ACTION) + * directly if you need the older "every tick" semantics. + */ + do_action( 'wp_stream_auto_purge' ); + + // Snapshot the UTC cutoff once per recurring tick. Each batch in this + // chain operates against this fixed cutoff so the chain is finite. + $days = (int) $options['general_records_ttl']; + $cutoff = ( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) ) + ->sub( DateInterval::createFromDateString( $days . ' days' ) ) + ->format( 'Y-m-d H:i:s' ); + + // blog_id = 0 means "all blogs" (network-activated path). + $blog_id = $this->admin->plugin->is_multisite_not_network_activated() ? (int) get_current_blog_id() : 0; + + global $wpdb; + + // "Is this a large table?" decision matches the manual reset path + // (Admin::erase_stream_records()). When the table is small the cost + // of scheduling a chain (and waiting for AS to drain it on the next + // runner tick) exceeds the cost of a single inline DELETE. Only fall + // through to the batched chain when the filter says "yes, large". + if ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $record_count = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id` = %d", $blog_id ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $record_count = (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->stream}" ); + } + + if ( ! $this->admin->plugin->is_large_records_table( $record_count ) ) { + // Small-table fast path: one inline multi-table DELETE, then enqueue + // the orphan reaper as a one-shot async action so the heal step is + // still observable in Tools → Scheduled Actions. + if ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`created` < %s AND `stream`.`blog_id` = %d;", + $cutoff, + $blog_id + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`created` < %s;", + $cutoff + ) + ); + } + + $this->admin->plugin->scheduler->enqueue_async( Admin::AUTO_PURGE_REAPER_ACTION, array(), Admin::AUTO_PURGE_GROUP ); + return; + } + + // Large-table path: batched chain. + $this->admin->plugin->scheduler->enqueue_async( + Admin::AUTO_PURGE_BATCH_ACTION, + array( + 'cutoff' => $cutoff, + 'blog_id' => $blog_id, + ), + Admin::AUTO_PURGE_GROUP + ); + + $this->maybe_warn_large_table_without_action_scheduler( + $record_count, + __( 'delete records older than the retention period', 'stream' ) + ); + } + + /** + * Async Action Scheduler callback: delete one batch of records eligible + * under the snapshotted UTC cutoff, then chain the next batch (or the + * orphan reaper when nothing remains). + * + * Window-based deletion mirrors {@see Admin_Purge::erase_large_records()} so the + * InnoDB lock footprint is bounded and predictable on bloated tables. + * + * @param string $cutoff MySQL DATETIME string in UTC. + * @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated). + * @param int $last_entry The lower-bound ID of the previous batch's window; 0 on the + * first batch in a chain. The next SELECT uses `ID < last_entry` + * when non-zero, guaranteeing forward progress even on tables + * that grow rapidly during the chain. Trade-off: any eligible + * row that lands inside the already-touched ID range + * [window_low, start_from] after that batch ran is skipped + * by the current chain and picked up on the next recurring + * tick (or small-table fast path). Possible sources: dev/test + * seeders, importer/migration plugins replaying historical + * rows, or PHP/MySQL clock skew on `created`. Steady-state + * logging via Log::log() uses monotonic IDs and current UTC, + * so this is a no-op for normal production traffic. + * @throws \InvalidArgumentException When $cutoff is empty (signals AS to mark the action as failed). + * @return void + */ + public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) { + global $wpdb; + + $cutoff = (string) $cutoff; + $blog_id = (int) $blog_id; + $last_entry = (int) $last_entry; + + // Defensive: a malformed cutoff would otherwise translate to a no-op + // DELETE that still busies the DB. Throw so Action Scheduler marks + // the action as failed (and visible in Tools → Scheduled Actions) + // rather than silently completing. In practice this is unreachable + // because purge_scheduled_action() always populates the cutoff arg + // and AS args are immutable; the guard exists for third-party code + // that may enqueue the action with bad input. + if ( '' === $cutoff ) { + throw new \InvalidArgumentException( 'auto_purge_batch requires a non-empty cutoff.' ); + } + + // Best-effort "running" marker for schedulers without a native RUNNING + // store (cron). Bridges the gap between this batch starting and the + // next chained event being enqueued; self-expires on a fatal. No-op + // under Action Scheduler. Cleared when the chain reaches its terminal + // reaper (see the empty-$start_from branch below). + $this->admin->plugin->scheduler->mark_running( 'auto_purge' ); + + /** + * Filters the number of records to delete per batch. + * + * Shared with the manual reset path (see {@see Admin_Purge::erase_large_records()}) + * so site owners only need to tune one knob. + * + * @since 4.1.0 + * + * @param int $batch_size Default 250000. + */ + $batch_size = (int) apply_filters( 'wp_stream_batch_size', 250000 ); + if ( $batch_size < 1 ) { + $batch_size = 250000; + } + + // Find the highest-ID record still eligible under the snapshotted cutoff + // that lies strictly below the previous window's lower bound (when set). + // $last_entry=0 means "first batch in chain" — search from the top. + if ( $blog_id > 0 && $last_entry > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d AND `ID` < %d ORDER BY ID DESC LIMIT 1", + $cutoff, + $blog_id, + $last_entry + ) + ); + } elseif ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1", + $cutoff, + $blog_id + ) + ); + } elseif ( $last_entry > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `ID` < %d ORDER BY ID DESC LIMIT 1", + $cutoff, + $last_entry + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1", + $cutoff + ) + ); + } + + if ( empty( $start_from ) ) { + // Chain is done. Schedule the orphan reaper as the terminal step. + // The running marker is NOT cleared here: under WP-Cron the reaper + // event is removed from the cron array before its callback runs, + // so clearing now would let the overlap guard read "idle" while + // the reaper's orphan-meta DELETE is still executing. The reaper + // clears the marker itself when it finishes. + $this->admin->plugin->scheduler->enqueue_async( Admin::AUTO_PURGE_REAPER_ACTION, array(), Admin::AUTO_PURGE_GROUP ); + return; + } + + $start_from = (int) $start_from; + $window_low = max( 0, $start_from - $batch_size ); + + // Multi-table DELETE: parent + meta in one statement. Mirrors + // Admin_Purge::erase_large_records(). + if ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`ID` <= %d + AND `stream`.`ID` >= %d + AND `stream`.`created` < %s + AND `stream`.`blog_id` = %d;", + $start_from, + $window_low, + $cutoff, + $blog_id + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`ID` <= %d + AND `stream`.`ID` >= %d + AND `stream`.`created` < %s;", + $start_from, + $window_low, + $cutoff + ) + ); + } + + // Chain the next batch. Pass $window_low as the new upper bound so the + // next SELECT cannot pick up rows in or above the window we just touched. + $this->admin->plugin->scheduler->enqueue_async( + Admin::AUTO_PURGE_BATCH_ACTION, + array( + 'cutoff' => $cutoff, + 'blog_id' => $blog_id, + 'last_entry' => $window_low, + ), + Admin::AUTO_PURGE_GROUP + ); + } + + /** + * Terminal Action Scheduler callback for the auto-purge chain. + * + * Runs once per chain (after the last batch) and once when the manual + * "Clean orphaned meta now" button is used. Cleans up meta rows whose + * parent stream row is already gone — i.e. residue from historical + * unbatched purges and from any logger races during a chain. + * + * @return void + */ + public function auto_purge_reaper() { + // Keep the overlap guard reading "busy" while the orphan-meta DELETE + // runs. Under WP-Cron the event is removed from the cron array before + // this callback executes, so without the marker a recurring purge + // tick or a manual "clean orphaned meta" click could stack parallel + // work against the same rows. No-op under Action Scheduler, which + // tracks RUNNING state natively. Self-expires on a fatal. + $this->admin->plugin->scheduler->mark_running( 'auto_purge' ); + + $this->delete_orphaned_meta(); + + $this->admin->plugin->scheduler->mark_done( 'auto_purge' ); + } +} diff --git a/classes/class-admin-screen-records.php b/classes/class-admin-screen-records.php new file mode 100644 index 000000000..5b91cfac8 --- /dev/null +++ b/classes/class-admin-screen-records.php @@ -0,0 +1,47 @@ +admin->list_table->prepare_items(); + ?> +
+

+ admin->list_table->display(); ?> +
+ admin->list_table = new List_Table( + $this->admin->plugin, + array( + 'screen' => $this->admin->menu->screen_id['main'], + ) + ); + } +} diff --git a/classes/class-admin-screen-settings.php b/classes/class-admin-screen-settings.php new file mode 100644 index 000000000..39202dcb1 --- /dev/null +++ b/classes/class-admin-screen-settings.php @@ -0,0 +1,120 @@ +register_hooks(); + } + + /** + * Register WordPress actions and filters for the settings screen. + */ + private function register_hooks(): void { + add_action( 'admin_notices', array( $this, 'display_feature_request_notice' ) ); + } + + /** + * Display a feature request notice. + * + * @action admin_notices + * + * @return void + */ + public function display_feature_request_notice() { + $screen = get_current_screen(); + + // Display the notice only on the Stream settings page. + if ( empty( $this->admin->menu->screen_id['settings'] ) || $this->admin->menu->screen_id['settings'] !== $screen->id ) { + return; + } + + printf( + '

%1$s %2$s

', + esc_html__( 'Have suggestions or found a bug?', 'stream' ), + esc_html__( 'Click here to let us know!', 'stream' ) + ); + } + + /** + * Render settings page + */ + public function render_settings_page() { + $option_key = $this->admin->plugin->settings->option_key; + $form_action = apply_filters( 'wp_stream_settings_form_action', admin_url( 'options.php' ) ); + + $page_description = apply_filters( 'wp_stream_settings_form_description', '' ); + + $sections = $this->admin->plugin->settings->get_fields(); + $active_tab = wp_stream_filter_input( INPUT_GET, 'tab' ); + + $this->admin->plugin->enqueue_asset( + 'settings', + array(), + array( + 'i18n' => array( + 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ), + ), + ) + ); + ?> +
+

+ + +

+ + + + + 1 ) : ?> + + + + +
+ menu = new Admin_Menu( $this ); + $this->assets = new Admin_Assets( $this ); + $this->records = new Admin_Screen_Records( $this ); + $this->settings = new Admin_Screen_Settings( $this ); + $this->purge = new Admin_Purge( $this ); + $this->ajax = new Admin_Ajax( $this, $this->purge ); - // Ensure function used in various methods is pre-loaded. - if ( ! function_exists( 'is_plugin_active_for_network' ) ) { - require_once ABSPATH . '/wp-admin/includes/plugin.php'; - } + $this->register_hooks(); + } + + /** + * Register WordPress actions and filters for the admin façade. + * + * Hook identities for cross-cutting admin behaviour remain on this instance + * (`array( $this, 'method' )`). Screen-specific, asset, Ajax, and purge + * hooks are registered on their collaborator classes. + */ + private function register_hooks(): void { + add_action( 'init', array( $this, 'init' ) ); // User and role caps. add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 ); add_filter( 'role_has_cap', array( $this, 'filter_role_caps' ), 10, 3 ); - if ( $this->plugin->is_multisite_network_activated() && ! is_network_admin() ) { - $options = (array) get_site_option( 'wp_stream_network', array() ); - $option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1; - - $this->disable_access = ( $option ) ? false : true; - } - - // Register settings page. - if ( ! $this->disable_access ) { - add_action( 'admin_menu', array( $this, 'register_menu' ) ); - } - // Admin notices. add_action( 'admin_notices', array( $this, 'prepare_admin_notices' ) ); add_action( 'shutdown', array( $this, 'admin_notices' ) ); - // Feature request notice. - add_action( 'admin_notices', array( $this, 'display_feature_request_notice' ) ); - - // Add admin body class. - add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) ); - // Plugin action links. add_filter( 'plugin_action_links', @@ -195,79 +216,6 @@ public function __construct( public $plugin ) { 10, 2 ); - - // Load admin scripts and styles. - add_action( - 'admin_enqueue_scripts', - array( - $this, - 'admin_enqueue_scripts', - ) - ); - add_action( 'admin_enqueue_scripts', array( $this, 'admin_menu_css' ) ); - - // Reset Streams database. - add_action( - 'wp_ajax_wp_stream_reset', - array( - $this, - 'wp_ajax_reset', - ) - ); - - // Manual "Clean orphaned meta now" action (Settings → Advanced). - add_action( - 'wp_ajax_wp_stream_clean_orphan_meta', - array( $this, 'wp_ajax_clean_orphan_meta' ) - ); - - // Render confirmation notices keyed by the wp_stream_message query - // arg set on post-action redirects (e.g. orphan_meta_cleanup_scheduled). - add_action( 'admin_notices', array( $this, 'maybe_display_message' ) ); - add_action( 'network_admin_notices', array( $this, 'maybe_display_message' ) ); - - // Render the persisted "large batched operation queued to WP-Cron" - // warning on the next admin page load (see - // maybe_warn_large_table_without_action_scheduler()). - add_action( 'admin_notices', array( $this, 'display_large_table_cron_notice' ) ); - add_action( 'network_admin_notices', array( $this, 'display_large_table_cron_notice' ) ); - - // Auto purge setup (Action Scheduler). - add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) ); - add_action( - self::AUTO_PURGE_ACTION, - array( $this, 'purge_scheduled_action' ) - ); - add_action( - self::AUTO_PURGE_BATCH_ACTION, - array( $this, 'auto_purge_batch' ), - 10, - 3 - ); - add_action( - self::AUTO_PURGE_REAPER_ACTION, - array( $this, 'auto_purge_reaper' ) - ); - - // Ajax users list. - add_action( - 'wp_ajax_wp_stream_filters', - array( - $this, - 'ajax_filters', - ) - ); - - // Async action for erasing large log tables. - add_action( - self::ASYNC_DELETION_ACTION, - array( - $this, - 'erase_large_records', - ), - 10, - 4 - ); } /** @@ -282,7 +230,7 @@ public function init() { // Check if the host has configured the `REMOTE_ADDR` correctly. $client_ip = $this->plugin->get_client_ip_address(); - if ( empty( $client_ip ) && $this->is_stream_screen() ) { + if ( empty( $client_ip ) && $this->assets->is_stream_screen() ) { $this->notice( __( 'Stream plugin can\'t determine a reliable client IP address! Please update the hosting environment to set the $_SERVER[\'REMOTE_ADDR\'] variable or use the wp_stream_client_ip_address filter to specify the verified client IP address!', 'stream' ) ); } } @@ -361,639 +309,6 @@ public function admin_notices() { } } - /** - * Display a feature request notice. - * - * @return void - */ - public function display_feature_request_notice() { - $screen = get_current_screen(); - - // Display the notice only on the Stream settings page. - if ( empty( $this->screen_id['settings'] ) || $this->screen_id['settings'] !== $screen->id ) { - return; - } - - printf( - '

%1$s %2$s

', - esc_html__( 'Have suggestions or found a bug?', 'stream' ), - esc_html__( 'Click here to let us know!', 'stream' ) - ); - } - - /** - * Register menu page - * - * @action admin_menu - * - * @return void - */ - public function register_menu() { - /** - * Filter the main admin menu title - * - * @return string - */ - $main_menu_title = apply_filters( 'wp_stream_admin_menu_title', esc_html__( 'Stream', 'stream' ) ); - - /** - * Filter the main admin menu position - * - * Note: Using longtail decimal string to reduce the chance of position conflicts, see Codex - * - * @return string - */ - $main_menu_position = apply_filters( 'wp_stream_menu_position', '2.999999' ); - - /** - * Filter the main admin page title - * - * @return string - */ - $main_page_title = apply_filters( 'wp_stream_admin_page_title', esc_html__( 'Stream Records', 'stream' ) ); - - $this->screen_id['main'] = add_menu_page( - $main_page_title, - $main_menu_title, - $this->view_cap, - $this->records_page_slug, - array( $this, 'render_list_table' ), - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9IjAwMCI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo=', - $main_menu_position - ); - - /** - * Fires before submenu items are added to the Stream menu - * allowing plugins to add menu items before Settings - * - * @return void - */ - do_action( 'wp_stream_admin_menu' ); - - /** - * Filter the Settings admin page title - * - * @return string - */ - $settings_page_title = apply_filters( 'wp_stream_settings_form_title', esc_html__( 'Stream Settings', 'stream' ) ); - - $this->screen_id['settings'] = add_submenu_page( - $this->records_page_slug, - $settings_page_title, - esc_html__( 'Settings', 'stream' ), - $this->settings_cap, - $this->settings_page_slug, - array( $this, 'render_settings_page' ) - ); - - if ( isset( $this->screen_id['main'] ) ) { - /** - * Fires just before the Stream list table is registered. - * - * @return void - */ - do_action( 'wp_stream_admin_menu_screens' ); - - // Register the list table early, so it associates the column headers with 'Screen settings'. - add_action( - 'load-' . $this->screen_id['main'], - array( - $this, - 'register_list_table', - ) - ); - } - } - - /** - * Enqueue scripts/styles for admin screen - * - * @action admin_enqueue_scripts - * - * @param string $hook Current hook. - * - * @return void - */ - public function admin_enqueue_scripts( $hook ) { - if ( in_array( $hook, $this->screen_id, true ) ) { - $this->plugin->enqueue_asset( - 'admin', - array( - $this->plugin->with_select2(), - $this->plugin->with_jquery_timeago(), - ), - array( - 'i18n' => array( - 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ), - 'confirm_defaults' => __( 'Are you sure you want to reset all site settings to default? This cannot be undone.', 'stream' ), - ), - 'locale' => strtolower( substr( get_locale(), 0, 2 ) ), - 'gmt_offset' => get_option( 'gmt_offset' ), - ) - ); - - $this->plugin->enqueue_asset( - 'admin-exclude', - array( - $this->plugin->with_select2(), - ), - array( - 'getActionsNonce' => wp_create_nonce( 'stream_get_actions' ), - ) - ); - - $current_order = isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( ! in_array( $current_order, array( 'asc', 'desc' ), true ) ) { - $current_order = 'desc'; - } - $current_query = map_deep( wp_unslash( $_GET ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended - - $this->plugin->enqueue_asset( - 'live-updates', - array( 'heartbeat' ), - array( - 'current_screen' => $hook, - 'current_page' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : '1', // phpcs:ignore WordPress.Security.NonceVerification.Recommended - 'current_order' => $current_order, - 'current_query' => wp_json_encode( $current_query ), - 'current_query_count' => count( $current_query ), - ) - ); - } - - /** - * The maximum number of items that can be updated in bulk without receiving a warning. - * - * Stream watches for bulk actions performed in the WordPress Admin (such as updating - * many posts at once) and warns the user before proceeding if the number of items they - * are attempting to update exceeds this threshold value. Since Stream will try to save - * a log for each item, it will take longer than usual to complete the operation. - * - * The default threshold is 100 items. - * - * @return int - */ - $bulk_actions_threshold = apply_filters( 'wp_stream_bulk_actions_threshold', 100 ); - - $this->plugin->enqueue_asset( - 'global', - array(), - array( - 'bulk_actions' => array( - 'i18n' => array( - /* translators: %s: a number of items (e.g. "1,742") */ - 'confirm_action' => sprintf( __( 'Are you sure you want to perform bulk actions on over %s items? This process could take a while to complete.', 'stream' ), number_format( absint( $bulk_actions_threshold ) ) ), - ), - 'threshold' => absint( $bulk_actions_threshold ), - ), - 'plugins_screen_url' => self_admin_url( 'plugins.php#stream' ), - ) - ); - } - - /** - * Check whether or not the current admin screen belongs to Stream - * - * @return bool - */ - public function is_stream_screen() { - if ( ! is_admin() ) { - return false; - } - - $page = wp_stream_filter_input( INPUT_GET, 'page' ); - if ( is_string( $page ) && false !== strpos( $page, $this->records_page_slug ) ) { - return true; - } - - if ( is_admin() && function_exists( 'get_current_screen' ) ) { - $screen = get_current_screen(); - - return ( Alerts::POST_TYPE === $screen->post_type ); - } - - return false; - } - - /** - * Add a specific body class to all Stream admin screens - * - * @param string $classes CSS classes to output to body. - * - * @filter admin_body_class - * - * @return string - */ - public function admin_body_class( $classes ) { - $stream_classes = array(); - - if ( $this->is_stream_screen() ) { - $stream_classes[] = $this->admin_body_class; - - if ( isset( $_GET['page'] ) ) { // // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $stream_classes[] = sanitize_key( $_GET['page'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended - } - } - - /** - * Filter the Stream admin body classes - * - * @return array - */ - $stream_classes = apply_filters( 'wp_stream_admin_body_classes', $stream_classes ); - $stream_classes = implode( ' ', array_map( 'trim', $stream_classes ) ); - - return sprintf( '%s %s ', $classes, $stream_classes ); - } - - /** - * Add menu styles for various WP Admin skins. - * - * @action admin_enqueue_scripts - */ - public function admin_menu_css() { - // Make sure we're working off a clean version. - if ( ! file_exists( ABSPATH . WPINC . '/version.php' ) ) { - return; - } - include ABSPATH . WPINC . '/version.php'; - - if ( ! isset( $wp_version ) ) { - return; - } - - $css = " - body.{$this->admin_body_class} #wpbody-content .wrap h1:nth-child(1):before { - content: ''; - display: inline-block; - width: 24px; - height: 24px; - margin-right: 8px; - vertical-align: text-bottom; - background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZpbGw9ImN1cnJlbnRjb2xvciI+Cgk8cGF0aCBkPSJNOTAzLjExNSA1MTUuNDEzYy00OS4zOTIgMC05MS40NzQgMzEuMzM3LTEwNy40NiA3NS4yMDNsLTEyNC40MTEtMS41MzJjLTExLjM3Ny0uMzQ2LTIyLjc1MS0uNjg5LTM0LjEyOS0uOTk4bC0uMjQxLjU3NC0yMi40MzYtLjI3OC0uMTUzLS45Mi0xNS4wNTYtODIuOTMzLTIwLjE0Ni0xMDguNDA1LTIwLjU0NC0xMDguMzM3TDUwMy45ODIgMGwtNTMuMTQxIDQyOS4wMTMtMTYuMjE0IDEzNy45MzUtMTIuMDE2IDEwNi45MjQtMTE3LjI4Ni0yODUuMjItMTguMzUzIDIwMi44MWMtNDIuNTYyIDEuNDU0LTg1LjEyNyAyLjkzNC0xMjcuNjg4IDQuNzM4LTUzLjA5NyAyLjI5Mi0xMDYuMTg3IDQuNDczLTE1OS4yODQgNy41MzZ2NDIuMDQyYzUzLjA5NyAzLjA2IDEwNi4xODcgNS4yNDcgMTU5LjI4NCA3LjUzMyA1My4wOTMgMi4yNDUgMTA2LjE4IDQuMTk0IDE1OS4yNzMgNS45MDNsMTQuMjQuNDY1IDE3LjM1MSA0OC4zOWMxOC44NDIgNTEuODc0IDM3LjU0MiAxMDMuODA2IDU2Ljc2NSAxNTUuNTQxTDQ2Ni41MiAxMDI0bDQxLjUxMi0zMDguMjkzIDE3LjYzMy0xMzYuNjg1IDEwLjc3NiA1MC4zMjkgNTQuODE1IDI0OC41NDQgNzIuNTE2LTIxNy4yMTdoMTI5LjI2MWMxMy40OTMgNDguMTIxIDU3LjY1NSA4My40MjkgMTEwLjA3NSA4My40MjkgNjMuMTYgMCAxMTQuMzUyLTUxLjIwNSAxMTQuMzUyLTExNC4zNDggMC02My4xMzktNTEuMTg5LTExNC4zNDUtMTE0LjM0OS0xMTQuMzQ1bC4wMDQtLjAwMVoiIC8+Cjwvc3ZnPgo='); - } - #menu-posts-feedback .wp-menu-image:before { - font-family: dashicons !important; - content: '\\f175'; - } - #adminmenu #menu-posts-feedback div.wp-menu-image { - background: none !important; - background-repeat: no-repeat; - } - "; - - wp_add_inline_style( 'wp-admin', $css ); - } - - /** - * Handle the reset AJAX request to reset logs. - * - * @return bool - */ - public function wp_ajax_reset() { - check_ajax_referer( 'stream_nonce_reset', 'wp_stream_nonce_reset' ); - - if ( ! current_user_can( $this->settings_cap ) ) { - wp_die( - esc_html__( "You don't have sufficient privileges to do this action.", 'stream' ) - ); - } - - // Ensure the database tables exist before attempting to clear records. - // Install::check() short-circuits on DOING_AJAX, so call install() - // directly. dbDelta is idempotent and safe to run when tables already - // exist. - $this->plugin->install->install( $this->plugin->get_version() ); - - $this->erase_stream_records(); - - if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) { - return true; - } - - wp_safe_redirect( - add_query_arg( - array( - 'page' => is_network_admin() ? $this->network->network_settings_page_slug : $this->settings_page_slug, - 'message' => 'data_erased', - ), - self_admin_url( $this->admin_parent_page ) - ) - ); - - exit; - } - - /** - * Clears stream records from the database. - * - * @return void - */ - private function erase_stream_records() { - global $wpdb; - - // If this is a multisite and it's not network activated, - // only delete the entries from the blog which made the request. - if ( $this->plugin->is_multisite_not_network_activated() ) { - - // First check the log size. - $stream_log_size = self::get_blog_record_table_size(); - - // If this is a large log and we need to delete only the entries - // pertaining to an individual site, we will need to do those in batches. - if ( $this->plugin->is_large_records_table( $stream_log_size ) ) { - $this->schedule_erase_large_records( $stream_log_size ); - return; - } - - $wpdb->query( - $wpdb->prepare( - "DELETE `stream`, `meta` - FROM {$wpdb->stream} AS `stream` - LEFT JOIN {$wpdb->streammeta} AS `meta` - ON `meta`.`record_id` = `stream`.`ID` - WHERE `blog_id`=%d;", - get_current_blog_id() - ) - ); - } else { - // If we are deleting all the entries, we can truncate the tables. - $wpdb->query( "TRUNCATE {$wpdb->streammeta};" ); - $wpdb->query( "TRUNCATE {$wpdb->stream};" ); - // Tidy up any meta which may have been added in between the two truncations. - $this->delete_orphaned_meta(); - } - } - - /** - * Schedule the initial event to start erasing the logs from now. - * - * @param int $log_size The number of rows which will be affected. - * @return void - */ - private function schedule_erase_large_records( int $log_size ) { - global $wpdb; - - $last_entry = $wpdb->get_var( - $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `blog_id`=%d ORDER BY ID DESC LIMIT 1", - get_current_blog_id() - ) - ); - - // If there are no entries to erase, don't try to erase them. - if ( empty( $last_entry ) ) { - return; - } - - // We are going to delete this many and this many only. - // This is to avoid the situation where rows keep getting added - // between the Action Scheduler runs and they never stop. - $args = array( - 'total' => (int) $log_size, - 'done' => 0, - 'last_entry' => (int) $last_entry, - 'blog_id' => (int) get_current_blog_id(), - ); - - $this->plugin->scheduler->enqueue_async( self::ASYNC_DELETION_ACTION, $args ); - - $this->maybe_warn_large_table_without_action_scheduler( - (int) $log_size, - __( 'reset the Stream database (delete all records for this site)', 'stream' ) - ); - } - - /** - * Warn when a large-table batched operation has to lean on WP-Cron. - * - * Action Scheduler is purpose-built to drain long self-chaining batch - * jobs reliably; default WP-Cron fires opportunistically on traffic and - * can stall a multi-hour chain on a low-traffic site. When Stream is - * running the WP-Cron fallback (the `wp_stream_use_action_scheduler` - * filter returned false, or the bundled AS library is absent) against a - * table over the large-table threshold, surface a notice pointing the - * operator at a deterministic WP-CLI drain instead of failing silently. - * - * Delivery depends on context. Under WP-CLI the warning is emitted - * immediately via {@see Admin::notice()} (WP_CLI::warning) — scheduling - * the batch chain onto WP-Cron does not drain it, so a headless / - * low-traffic site is exactly where the chain can stall. Outside WP-CLI - * neither call site renders its own output (the recurring purge runs - * under DOING_CRON; the manual reset redirects and exits before its - * shutdown hook output reaches the browser), so the message is persisted - * to {@see Admin::LARGE_TABLE_CRON_NOTICE_OPTION} and rendered on the - * next admin page load by {@see Admin::display_large_table_cron_notice()}. - * - * No-op when Action Scheduler is the active backend (built to drain long - * chains). The `wp_stream_enable_auto_purge` filter deliberately does NOT - * gate this helper: it governs TTL retention purging only, while this - * warning also covers the manual database reset — an operator who manages - * retention externally can still click "Reset Stream Database" and needs - * the stall warning. The auto-purge call site is already gated by the - * filter's early return in {@see Admin::purge_scheduled_action()}. - * - * @param int $record_count Number of rows the operation will touch. - * @param string $operation Human-readable, translated description of what the - * batched work does (e.g. "delete records older than - * the retention period"), interpolated into the notice. - * @return void - */ - private function maybe_warn_large_table_without_action_scheduler( int $record_count, string $operation ) { - if ( $this->plugin->scheduler instanceof AS_Scheduler ) { - return; - } - - if ( ! $this->plugin->is_large_records_table( $record_count ) ) { - return; - } - - $message = sprintf( - /* translators: 1: operation description (e.g. "delete records older than the retention period"), 2: number of records, 3: WP-CLI command. */ - __( 'Stream queued a large batched operation to %1$s (%2$s records) to WP-Cron because Action Scheduler is disabled. The records are removed in chained batches as WP-Cron runs. This completes on its own where reliable cron is configured (a Linux crontab or third-party cron service triggering wp-cron.php on a fixed interval, without an execution timeout). On sites relying on default traffic-triggered WP-Cron the chain may stall before it finishes, leaving records only partly removed; to run it to completion deterministically, use WP-CLI: %3$s', 'stream' ), - $operation, - number_format_i18n( $record_count ), - 'wp cron event run --due-now' - ); - - if ( defined( 'WP_CLI' ) && WP_CLI ) { - // Immediate WP_CLI::warning — the operator is watching the terminal. - $this->notice( $message ); - return; - } - - // Persist for the next admin page load. Neither call site can render - // output itself: the recurring purge runs under DOING_CRON (response - // discarded) and the manual reset redirects + exits before shutdown - // output reaches the browser. No autoload — this is set rarely and - // read only in the admin. - update_option( self::LARGE_TABLE_CRON_NOTICE_OPTION, $message, false ); - } - - /** - * Render (and clear) the persisted large-table WP-Cron warning. - * - * Counterpart to {@see Admin::maybe_warn_large_table_without_action_scheduler()}: - * displays the stored warning on the first admin page an operator with - * the Stream settings capability loads after a large batched operation - * was queued onto WP-Cron. - * - * @action admin_notices - * @action network_admin_notices - * - * @return void - */ - public function display_large_table_cron_notice() { - if ( ! current_user_can( $this->settings_cap ) ) { - return; - } - - $message = get_option( self::LARGE_TABLE_CRON_NOTICE_OPTION ); - if ( empty( $message ) ) { - return; - } - - delete_option( self::LARGE_TABLE_CRON_NOTICE_OPTION ); - - printf( - '
%s
', - wp_kses_post( wpautop( $message ) ) - ); - } - - /** - * Checks if the async deletion process is running. - * - * Checks pending AND in-flight state, mirroring - * {@see Admin::is_running_auto_purge()}. Under WP-Cron the event is - * removed from the cron array before its callback runs, so a - * pending-only probe would momentarily read idle mid-chain and briefly - * re-expose the reset link in Settings. The batch worker keeps the - * best-effort running marker set for that window (see - * {@see Admin::erase_large_records()}). The marker transient is shared - * with the auto-purge chain, which only makes both guards more - * conservative — never less safe. - * - * @return bool True if the async deletion process is running, false otherwise. - */ - public static function is_running_async_deletion() { - $plugin = wp_stream_get_instance(); - if ( empty( $plugin->scheduler ) ) { - return false; - } - return $plugin->scheduler->any_pending_or_running( array( self::ASYNC_DELETION_ACTION ) ); - } - - /** - * Checks if any auto-purge action is currently scheduled or in-flight. - * - * Returns true when either the batched chain worker or the terminal - * orphan reaper is pending OR running. The recurring scheduler is - * intentionally excluded — it is always pending under normal operation, - * so including it here would make the probe useless. Used by the - * Settings → Advanced UI to render an "Auto-purge currently running" - * notice and by the recurring callback as an overlap guard. - * - * Checks both PENDING and IN-PROGRESS statuses so a chain that is - * mid-execution (e.g. the batch worker is currently running and has not - * yet enqueued the next batch) still reports as running. Without the - * RUNNING check the overlap guard can let a second parallel chain stack - * against the same rows. - * - * @return bool - */ - public static function is_running_auto_purge() { - $plugin = wp_stream_get_instance(); - if ( empty( $plugin->scheduler ) ) { - return false; - } - - return $plugin->scheduler->any_pending_or_running( - array( self::AUTO_PURGE_BATCH_ACTION, self::AUTO_PURGE_REAPER_ACTION ) - ); - } - - /** - * Erases large records from the stream table. - * - * This function deletes records from the stream table in batches, starting from a given entry ID. - * It deletes records in reverse chronological order, starting from the largest ID and going back. - * The number of records deleted in each batch is determined by the batch size, which can be filtered - * using the 'wp_stream_batch_size' hook. - * - * @param int $total The total number of records to be deleted. - * @param int $done The number of records that have already been deleted. - * @param int $last_entry The ID of the last entry that was deleted. - * @param int $blog_id The ID of the blog for which the records should be deleted. - * @return void - */ - public function erase_large_records( int $total, int $done, int $last_entry, int $blog_id ) { - global $wpdb; - - // Best-effort "running" marker, mirroring auto_purge_batch(). Under - // WP-Cron the event is dequeued before this callback runs, so without - // the marker is_running_async_deletion() would momentarily read idle - // between batches and briefly re-expose the reset link in Settings. - // No-op under Action Scheduler; self-expires on a fatal. - $this->plugin->scheduler->mark_running( 'async_deletion' ); - - $start_from = $wpdb->get_var( - $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE ID < %d AND `blog_id`=%d ORDER BY ID DESC LIMIT 1", - $last_entry + 1, // A tweak to get it correct the first time through. - get_current_blog_id() - ) - ); - - if ( empty( $start_from ) ) { - // Terminal batch: nothing left to delete, no further event will - // be chained, and no work follows within this callback — safe to - // clear the marker immediately (unlike the auto-purge chain, - // whose terminal batch hands off to the reaper). - $this->plugin->scheduler->mark_done( 'async_deletion' ); - return; - } - - /** - * Filters the number of records in the {$wpdb->stream} table to do at a time. - * - * @since 4.1.0 - * - * @param int $batch_size The batch size, default 250000. - */ - $batch_size = apply_filters( 'wp_stream_batch_size', 250000 ); - - // This will tend to erase them in reverse chronological order, - // ie it will start from the largest ID and go back from there. - $wpdb->query( - $wpdb->prepare( - "DELETE `stream`, `meta` - FROM {$wpdb->stream} AS `stream` - LEFT JOIN {$wpdb->streammeta} AS `meta` - ON `meta`.`record_id` = `stream`.`ID` - WHERE ID <= %d AND ID >= %d AND `blog_id`=%d;", - $start_from, - $start_from - $batch_size, - get_current_blog_id() - ) - ); - - $remaining = $wpdb->get_var( - $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d", $blog_id ) - ); - - $done = $total - $remaining; - - $this->plugin->scheduler->enqueue_async( - self::ASYNC_DELETION_ACTION, - array( - 'total' => (int) $total, - 'done' => (int) $done, - 'last_entry' => (int) $start_from - $batch_size, // The last ID checked. - 'blog_id' => (int) $blog_id, - ) - ); - } /** * Retrieves the size of the blog record table for a specific blog. @@ -1001,7 +316,7 @@ public function erase_large_records( int $total, int $done, int $last_entry, int * @param int|null $blog_id The ID of the blog. If not provided, the current blog ID will be used. * @return int The size of the blog record table. */ - public static function get_blog_record_table_size( $blog_id = null ): int { + public function get_blog_record_table_size( $blog_id = null ): int { global $wpdb; $blog_id = empty( $blog_id ) ? get_current_blog_id() : $blog_id; @@ -1016,564 +331,6 @@ public static function get_blog_record_table_size( $blog_id = null ): int { return (int) $blog_size; } - /** - * Schedules a purge of records. - * - * @return void - */ - public function purge_schedule_setup() { - // Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it - // cannot double-fire alongside the new recurring action. - if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) { - wp_clear_scheduled_hook( 'wp_stream_auto_purge' ); - } - - $scheduler = $this->plugin->scheduler; - - /** - * Filter whether Stream schedules its TTL record auto-purge at all. - * - * Custom storage drivers that manage retention externally (TTL - * indexes, partition rotation, a warehouse job, etc.) can return - * false to disable all TTL purge scheduling regardless of the - * scheduler backend. Any already-registered recurring purge is - * unscheduled from both backends so it cannot keep firing. - * - * @param bool $enabled Whether auto-purge scheduling is enabled. - */ - if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) { - // Tear down only once, then record the 'disabled' sentinel in the - // backend marker. This runs on every wp_loaded, so without the - // guard a permanently-disabled site would pay the unschedule - // probes on every request; with it, steady state is a single - // in-memory compare (the marker is autoloaded). The sentinel also - // covers a site upgrading with the filter already active (no - // marker yet, but a recurring action left by a previous version). - // The executing path is independently gated by the same filter in - // purge_scheduled_action(), so a stray entry that somehow survives - // cannot purge anything anyway. - if ( 'disabled' !== get_option( self::SCHEDULER_BACKEND_OPTION ) ) { - $scheduler->unschedule_all( self::AUTO_PURGE_ACTION ); - wp_unschedule_hook( self::AUTO_PURGE_ACTION ); - - // Also clear the Action Scheduler store when its API is - // available but AS is not the active backend (e.g. the cron - // backend is selected while WooCommerce provides AS). The - // active-backend unschedule above cannot see AS's store, and - // this filter promises teardown from BOTH backends. When AS - // is entirely absent this is skipped — a stray AS entry - // cannot execute (no AS runner), and if AS appears later the - // action fires as a no-op thanks to the execute-path gate. - if ( ! $scheduler instanceof AS_Scheduler && function_exists( 'as_unschedule_all_actions' ) ) { - ( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION ); - } - - update_option( self::SCHEDULER_BACKEND_OPTION, 'disabled' ); - } - return; - } - - $backend = $scheduler instanceof AS_Scheduler ? 'action_scheduler' : 'wp_cron'; - - // Detect a backend switch and clear the inactive backend's copy of the - // recurring action exactly once. A site that switched schedulers (via - // the wp_stream_use_action_scheduler filter) would otherwise keep - // firing the purge from BOTH backends — the two stores are independent - // and neither overlap guard can see the other. The marker is an - // autoloaded option, so the steady-state cost on every wp_loaded is a - // single in-memory compare; the cleanup query runs only on the first - // page load after a switch. Idempotent and self-healing. No data is - // affected — only the redundant schedule entry. - if ( get_option( self::SCHEDULER_BACKEND_OPTION ) !== $backend ) { - $cleanup_done = true; - - if ( 'action_scheduler' === $backend ) { - // Drop any leftover WP-Cron recurring event. - wp_unschedule_hook( self::AUTO_PURGE_ACTION ); - } elseif ( function_exists( 'as_unschedule_all_actions' ) ) { - // Drop any leftover Action Scheduler recurring action. Routed - // through AS_Scheduler so the as_*() call stays contained there. - ( new AS_Scheduler() )->unschedule_all( self::AUTO_PURGE_ACTION ); - } else { - // Action Scheduler is not loaded (cron backend selected and no - // other plugin provides AS), so its store cannot be cleaned - // right now. Do NOT write the marker: if an AS-providing - // plugin (e.g. WooCommerce) is installed later, the stray - // Stream recurring action in the AS store would resume firing - // alongside the cron one — and the cron overlap guard cannot - // see it. Leaving the marker stale retries this cleanup on a - // later request once as_unschedule_all_actions() exists. - $cleanup_done = false; - } - - if ( $cleanup_done ) { - update_option( self::SCHEDULER_BACKEND_OPTION, $backend ); - } - } - - // 12 hours == old `twicedaily` interval. The scheduler only schedules - // a fresh recurring action when one is not already registered. - $scheduler->schedule_recurring( - time(), - 12 * HOUR_IN_SECONDS, - self::AUTO_PURGE_ACTION, - array(), - self::AUTO_PURGE_GROUP - ); - } - - /** - * Deletes orphaned meta records from the database. - * - * Deletes meta records from the stream meta table where the corresponding - * stream record no longer exists. - * - * @global wpdb $wpdb The WordPress database object. - */ - protected function delete_orphaned_meta() { - global $wpdb; - - $wpdb->query( - "DELETE `meta` FROM {$wpdb->streammeta} as `meta` LEFT JOIN {$wpdb->stream} as `stream` ON `stream`.`ID`=`meta`.`record_id` WHERE `stream`.`ID` IS NULL" - ); - } - - /** - * Executes a scheduled purge - * - * @return void - */ - public function purge_scheduled_action() { - // Respect the auto-purge master switch on the executing path too, not - // just at scheduling time. A recurring action already in flight when - // the filter flips to false (or an args-specific entry the unschedule - // missed) would otherwise still run a purge cycle the operator opted - // out of. This filter is documented in Admin::purge_schedule_setup(). - if ( ! apply_filters( 'wp_stream_enable_auto_purge', true ) ) { - return; - } - - // Don't purge when in Network Admin unless Stream is network activated. - if ( - $this->plugin->is_multisite_not_network_activated() - && - is_network_admin() - ) { - return; - } - - $defaults = $this->plugin->settings->get_defaults(); - if ( $this->plugin->is_multisite_network_activated() ) { - $options = wp_parse_args( (array) get_site_option( 'wp_stream_network', array() ), $defaults ); - } else { - $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults ); - } - - // TTL fallback. Settings::get_defaults() runs every settings field - // through the `wp_stream_settings_option_fields` filter, which - // Network::get_network_admin_fields() uses to strip the `records_ttl` - // field from the per-site option's defaults set. When this callback runs - // outside any admin context (Action Scheduler, WP-CLI, system cron), the - // per-site option_key is in effect, so the filtered defaults array does - // not contain general_records_ttl at all. Apply the documented 30-day - // default (classes/class-settings.php, `records_ttl` field) only when - // the key is genuinely missing, so an operator who set the value via - // CLI/SQL keeps their explicit choice. - if ( ! isset( $options['general_records_ttl'] ) ) { - $options['general_records_ttl'] = 30; - } - - if ( ! empty( $options['general_keep_records_indefinitely'] ) ) { - return; - } - - // Refuse to purge with a non-positive TTL. The UI enforces min=1, but - // CLI/SQL can set 0 or a negative integer. Honoring those would mean - // "delete every record on every cycle", which has no legitimate use - // case (keep_records_indefinitely covers the opposite extreme). - // Bailing out makes operator error visible (records stop being purged) - // instead of catastrophic (records get wiped repeatedly). - if ( (int) $options['general_records_ttl'] < 1 ) { - return; - } - - // Overlap guard: if any auto-purge action (batch worker or reaper) is - // pending or in-progress, don't stack a new chain. Reuses the same - // probe used by the Settings UI so the two views of "running" agree. - if ( self::is_running_auto_purge() ) { - return; - } - - /** - * Fires once per auto-purge cycle, after all bail-out checks pass and - * immediately before deletion work is enqueued. - * - * Preserved for backward compatibility with consumers that hooked the - * legacy WP-Cron event of the same name in Stream <= 4.1.x. Note that - * since 4.2.0 this fires only when a purge is actually about to run — - * it no longer fires on every cron tick regardless of whether work - * happens. Hook into the recurring AS action (Admin::AUTO_PURGE_ACTION) - * directly if you need the older "every tick" semantics. - */ - do_action( 'wp_stream_auto_purge' ); - - // Snapshot the UTC cutoff once per recurring tick. Each batch in this - // chain operates against this fixed cutoff so the chain is finite. - $days = (int) $options['general_records_ttl']; - $cutoff = ( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) ) - ->sub( DateInterval::createFromDateString( $days . ' days' ) ) - ->format( 'Y-m-d H:i:s' ); - - // blog_id = 0 means "all blogs" (network-activated path). - $blog_id = $this->plugin->is_multisite_not_network_activated() ? (int) get_current_blog_id() : 0; - - global $wpdb; - - // "Is this a large table?" decision matches the manual reset path - // (Admin::erase_stream_records()). When the table is small the cost - // of scheduling a chain (and waiting for AS to drain it on the next - // runner tick) exceeds the cost of a single inline DELETE. Only fall - // through to the batched chain when the filter says "yes, large". - if ( $blog_id > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $record_count = (int) $wpdb->get_var( - $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id` = %d", $blog_id ) - ); - } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $record_count = (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->stream}" ); - } - - if ( ! $this->plugin->is_large_records_table( $record_count ) ) { - // Small-table fast path: one inline multi-table DELETE, then enqueue - // the orphan reaper as a one-shot async action so the heal step is - // still observable in Tools → Scheduled Actions. - if ( $blog_id > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( - $wpdb->prepare( - "DELETE `stream`, `meta` - FROM {$wpdb->stream} AS `stream` - LEFT JOIN {$wpdb->streammeta} AS `meta` - ON `meta`.`record_id` = `stream`.`ID` - WHERE `stream`.`created` < %s AND `stream`.`blog_id` = %d;", - $cutoff, - $blog_id - ) - ); - } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( - $wpdb->prepare( - "DELETE `stream`, `meta` - FROM {$wpdb->stream} AS `stream` - LEFT JOIN {$wpdb->streammeta} AS `meta` - ON `meta`.`record_id` = `stream`.`ID` - WHERE `stream`.`created` < %s;", - $cutoff - ) - ); - } - - $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); - return; - } - - // Large-table path: batched chain. - $this->plugin->scheduler->enqueue_async( - self::AUTO_PURGE_BATCH_ACTION, - array( - 'cutoff' => $cutoff, - 'blog_id' => $blog_id, - ), - self::AUTO_PURGE_GROUP - ); - - $this->maybe_warn_large_table_without_action_scheduler( - $record_count, - __( 'delete records older than the retention period', 'stream' ) - ); - } - - /** - * Async Action Scheduler callback: delete one batch of records eligible - * under the snapshotted UTC cutoff, then chain the next batch (or the - * orphan reaper when nothing remains). - * - * Window-based deletion mirrors {@see Admin::erase_large_records()} so the - * InnoDB lock footprint is bounded and predictable on bloated tables. - * - * @param string $cutoff MySQL DATETIME string in UTC. - * @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated). - * @param int $last_entry The lower-bound ID of the previous batch's window; 0 on the - * first batch in a chain. The next SELECT uses `ID < last_entry` - * when non-zero, guaranteeing forward progress even on tables - * that grow rapidly during the chain. Trade-off: any eligible - * row that lands inside the already-touched ID range - * [window_low, start_from] after that batch ran is skipped - * by the current chain and picked up on the next recurring - * tick (or small-table fast path). Possible sources: dev/test - * seeders, importer/migration plugins replaying historical - * rows, or PHP/MySQL clock skew on `created`. Steady-state - * logging via Log::log() uses monotonic IDs and current UTC, - * so this is a no-op for normal production traffic. - * @throws \InvalidArgumentException When $cutoff is empty (signals AS to mark the action as failed). - * @return void - */ - public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) { - global $wpdb; - - $cutoff = (string) $cutoff; - $blog_id = (int) $blog_id; - $last_entry = (int) $last_entry; - - // Defensive: a malformed cutoff would otherwise translate to a no-op - // DELETE that still busies the DB. Throw so Action Scheduler marks - // the action as failed (and visible in Tools → Scheduled Actions) - // rather than silently completing. In practice this is unreachable - // because purge_scheduled_action() always populates the cutoff arg - // and AS args are immutable; the guard exists for third-party code - // that may enqueue the action with bad input. - if ( '' === $cutoff ) { - throw new \InvalidArgumentException( 'auto_purge_batch requires a non-empty cutoff.' ); - } - - // Best-effort "running" marker for schedulers without a native RUNNING - // store (cron). Bridges the gap between this batch starting and the - // next chained event being enqueued; self-expires on a fatal. No-op - // under Action Scheduler. Cleared when the chain reaches its terminal - // reaper (see the empty-$start_from branch below). - $this->plugin->scheduler->mark_running( 'auto_purge' ); - - /** - * Filters the number of records to delete per batch. - * - * Shared with the manual reset path (see {@see Admin::erase_large_records()}) - * so site owners only need to tune one knob. - * - * @since 4.1.0 - * - * @param int $batch_size Default 250000. - */ - $batch_size = (int) apply_filters( 'wp_stream_batch_size', 250000 ); - if ( $batch_size < 1 ) { - $batch_size = 250000; - } - - // Find the highest-ID record still eligible under the snapshotted cutoff - // that lies strictly below the previous window's lower bound (when set). - // $last_entry=0 means "first batch in chain" — search from the top. - if ( $blog_id > 0 && $last_entry > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $start_from = $wpdb->get_var( - $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d AND `ID` < %d ORDER BY ID DESC LIMIT 1", - $cutoff, - $blog_id, - $last_entry - ) - ); - } elseif ( $blog_id > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $start_from = $wpdb->get_var( - $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1", - $cutoff, - $blog_id - ) - ); - } elseif ( $last_entry > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $start_from = $wpdb->get_var( - $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `ID` < %d ORDER BY ID DESC LIMIT 1", - $cutoff, - $last_entry - ) - ); - } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $start_from = $wpdb->get_var( - $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1", - $cutoff - ) - ); - } - - if ( empty( $start_from ) ) { - // Chain is done. Schedule the orphan reaper as the terminal step. - // The running marker is NOT cleared here: under WP-Cron the reaper - // event is removed from the cron array before its callback runs, - // so clearing now would let the overlap guard read "idle" while - // the reaper's orphan-meta DELETE is still executing. The reaper - // clears the marker itself when it finishes. - $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); - return; - } - - $start_from = (int) $start_from; - $window_low = max( 0, $start_from - $batch_size ); - - // Multi-table DELETE: parent + meta in one statement. Mirrors - // Admin::erase_large_records(). - if ( $blog_id > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( - $wpdb->prepare( - "DELETE `stream`, `meta` - FROM {$wpdb->stream} AS `stream` - LEFT JOIN {$wpdb->streammeta} AS `meta` - ON `meta`.`record_id` = `stream`.`ID` - WHERE `stream`.`ID` <= %d - AND `stream`.`ID` >= %d - AND `stream`.`created` < %s - AND `stream`.`blog_id` = %d;", - $start_from, - $window_low, - $cutoff, - $blog_id - ) - ); - } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->query( - $wpdb->prepare( - "DELETE `stream`, `meta` - FROM {$wpdb->stream} AS `stream` - LEFT JOIN {$wpdb->streammeta} AS `meta` - ON `meta`.`record_id` = `stream`.`ID` - WHERE `stream`.`ID` <= %d - AND `stream`.`ID` >= %d - AND `stream`.`created` < %s;", - $start_from, - $window_low, - $cutoff - ) - ); - } - - // Chain the next batch. Pass $window_low as the new upper bound so the - // next SELECT cannot pick up rows in or above the window we just touched. - $this->plugin->scheduler->enqueue_async( - self::AUTO_PURGE_BATCH_ACTION, - array( - 'cutoff' => $cutoff, - 'blog_id' => $blog_id, - 'last_entry' => $window_low, - ), - self::AUTO_PURGE_GROUP - ); - } - - /** - * Terminal Action Scheduler callback for the auto-purge chain. - * - * Runs once per chain (after the last batch) and once when the manual - * "Clean orphaned meta now" button is used. Cleans up meta rows whose - * parent stream row is already gone — i.e. residue from historical - * unbatched purges and from any logger races during a chain. - * - * @return void - */ - public function auto_purge_reaper() { - // Keep the overlap guard reading "busy" while the orphan-meta DELETE - // runs. Under WP-Cron the event is removed from the cron array before - // this callback executes, so without the marker a recurring purge - // tick or a manual "clean orphaned meta" click could stack parallel - // work against the same rows. No-op under Action Scheduler, which - // tracks RUNNING state natively. Self-expires on a fatal. - $this->plugin->scheduler->mark_running( 'auto_purge' ); - - $this->delete_orphaned_meta(); - - $this->plugin->scheduler->mark_done( 'auto_purge' ); - } - - /** - * Ajax handler for the "Clean orphaned meta now" button on - * Settings → Advanced. - * - * Schedules an immediate async run of the orphan reaper. Idempotent: - * if a reaper is already scheduled, returns without enqueuing a second. - * - * Returns true under WP_STREAM_TESTS so PHPUnit can call this directly - * without exiting the worker. - * - * @return bool|void True under tests; otherwise redirects and exits. - */ - public function wp_ajax_clean_orphan_meta() { - if ( ! current_user_can( $this->settings_cap ) ) { - wp_die( esc_html__( 'You do not have permission to do this.', 'stream' ), 403 ); - } - - check_ajax_referer( 'stream_nonce_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' ); - - if ( empty( $this->plugin->scheduler ) ) { - wp_die( esc_html__( 'No scheduler is available.', 'stream' ), 500 ); - } - - // Idempotency: skip enqueue when any auto-purge action is already - // pending or running. is_running_auto_purge() checks PENDING + RUNNING - // across the batch worker and the reaper, so a chain that will run - // its own terminal reaper is not duplicated by a manual click landing - // in the small CSRF/stale-URL window where the UI link is hidden. - if ( ! self::is_running_auto_purge() ) { - $this->plugin->scheduler->enqueue_async( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); - } - - if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) { - return true; - } - - $is_network = $this->plugin->is_multisite_network_activated(); - $page_slug = $is_network ? $this->network->network_settings_page_slug : $this->settings_page_slug; - $base_url = $is_network ? network_admin_url( $this->admin_parent_page ) : admin_url( $this->admin_parent_page ); - - wp_safe_redirect( - add_query_arg( - array( - 'page' => $page_slug, - 'wp_stream_message' => 'orphan_meta_cleanup_scheduled', - ), - $base_url - ) - ); - exit; - } - - /** - * Render admin notices for post-action redirects. - * - * Reads `wp_stream_message` from the query string and renders a matching - * notice. Used to surface "Clean Orphaned Meta" confirmation after the - * Ajax handler redirects back to Settings → Advanced. - * - * @return void - */ - public function maybe_display_message() { - $message = wp_stream_filter_input( INPUT_GET, 'wp_stream_message' ); - if ( empty( $message ) ) { - return; - } - - $notices = array( - 'orphan_meta_cleanup_scheduled' => __( - 'Orphaned meta cleanup scheduled. Progress is visible under Tools → Scheduled Actions.', - 'stream' - ), - ); - - if ( ! isset( $notices[ $message ] ) ) { - return; - } - - printf( - '

%s

', - esc_html( $notices[ $message ] ) - ); - } - /** * Returns the admin action links. * @@ -1615,99 +372,6 @@ public function plugin_action_links( $links, $file ) { return $links; } - /** - * Render main page - */ - public function render_list_table() { - $this->list_table->prepare_items(); - ?> -
-

- list_table->display(); ?> -
- plugin->settings->option_key; - $form_action = apply_filters( 'wp_stream_settings_form_action', admin_url( 'options.php' ) ); - - $page_description = apply_filters( 'wp_stream_settings_form_description', '' ); - - $sections = $this->plugin->settings->get_fields(); - $active_tab = wp_stream_filter_input( INPUT_GET, 'tab' ); - - $this->plugin->enqueue_asset( - 'settings', - array(), - array( - 'i18n' => array( - 'confirm_purge' => __( 'Are you sure you want to delete all Stream activity records from the database? This cannot be undone.', 'stream' ), - ), - ) - ); - ?> -
-

- - -

- - - - - 1 ) : ?> - - - - -
- list_table = new List_Table( - $this->plugin, - array( - 'screen' => $this->screen_id['main'], - ) - ); - } - /** * Check if a particular role has access * @@ -1795,119 +459,4 @@ public function filter_role_caps( $allcaps, $cap, $role ) { return $allcaps; } - - /** - * Ajax callback for return a user list. - * - * @action wp_ajax_wp_stream_filters - */ - public function ajax_filters() { - if ( ! defined( 'DOING_AJAX' ) || ! current_user_can( $this->plugin->admin->settings_cap ) ) { - wp_die( '-1' ); - } - - check_ajax_referer( 'stream_filters_user_search_nonce', 'nonce' ); - - switch ( wp_stream_filter_input( INPUT_GET, 'filter' ) ) { - case 'user_id': - $users = array_merge( - array( - 0 => (object) array( - 'display_name' => 'WP-CLI', - ), - ), - get_users() - ); - - $search = wp_stream_filter_input( INPUT_GET, 'q' ); - if ( $search ) { - // `search` arg for get_users() is not enough - $users = array_filter( - $users, - function ( $user ) use ( $search ) { - return false !== mb_strpos( mb_strtolower( $user->display_name ), mb_strtolower( $search ) ); - } - ); - } - - if ( count( $users ) > $this->preload_users_max ) { - $users = array_slice( $users, 0, $this->preload_users_max ); - } - - // Get gravatar / roles for final result set. - $results = $this->get_users_record_meta( $users ); - - break; - } - - if ( isset( $results ) ) { - echo wp_json_encode( $results ); - } - - die(); - } - - /** - * Return relevant user meta data. - * - * @param array $authors Author data. - * @return array - */ - public function get_users_record_meta( $authors ) { - $authors_records = array(); - - foreach ( $authors as $user_id => $args ) { - $author = new Author( $args->ID ); - - $authors_records[ $user_id ] = array( - 'text' => $author->get_display_name(), - 'id' => $author->id, - 'label' => $author->get_display_name(), - 'icon' => $author->get_avatar_src( 32 ), - 'title' => '', - ); - } - - return $authors_records; - } - - /** - * Get user meta in a way that is also safe for VIP - * - * @param int $user_id User ID. - * @param string $meta_key Meta key. - * @param bool $single Return first found meta value connected to the meta key (optional). - * - * @return mixed - */ - public function get_user_meta( $user_id, $meta_key, $single = true ) { - return get_user_meta( $user_id, $meta_key, $single ); - } - - /** - * Update user meta in a way that is also safe for VIP - * - * @param int $user_id User ID. - * @param string $meta_key Meta key. - * @param mixed $meta_value Meta value. - * @param mixed $prev_value Previous meta value being overwritten (optional). - * - * @return int|bool - */ - public function update_user_meta( $user_id, $meta_key, $meta_value, $prev_value = '' ) { - return update_user_meta( $user_id, $meta_key, $meta_value, $prev_value ); - } - - /** - * Delete user meta in a way that is also safe for VIP - * - * @param int $user_id User ID. - * @param string $meta_key Meta key. - * @param mixed $meta_value Meta value (optional). - * - * @return bool - */ - public function delete_user_meta( $user_id, $meta_key, $meta_value = '' ) { - return delete_user_meta( $user_id, $meta_key, $meta_value ); - } } diff --git a/classes/class-export.php b/classes/class-export.php index 471e55209..9b18e7982 100644 --- a/classes/class-export.php +++ b/classes/class-export.php @@ -58,7 +58,7 @@ public function render_download() { return; } - $this->plugin->admin->register_list_table(); + $this->plugin->admin->records->register_list_table(); $list_table = $this->plugin->admin->list_table; $list_table->prepare_items(); add_filter( 'stream_records_per_page', array( $this, 'disable_paginate' ) ); diff --git a/classes/class-list-table.php b/classes/class-list-table.php index e66b52e5c..bdc25bab2 100644 --- a/classes/class-list-table.php +++ b/classes/class-list-table.php @@ -1128,7 +1128,7 @@ public function screen_controls( $status, $args ) { unset( $args ); $user_id = get_current_user_id(); - $option = $this->plugin->admin->get_user_meta( $user_id, $this->plugin->admin->live_update->user_meta_key, true ); + $option = get_user_meta( $user_id, $this->plugin->admin->live_update->user_meta_key, true ); $heartbeat = wp_script_is( 'heartbeat', 'done' ) ? 'true' : 'false'; if ( 'on' === $option && 'false' === $heartbeat ) { diff --git a/classes/class-network.php b/classes/class-network.php index e3ff9db56..8e7d0f3bb 100644 --- a/classes/class-network.php +++ b/classes/class-network.php @@ -40,7 +40,7 @@ public function __construct( public $plugin ) { // Actions. add_action( 'init', array( $this, 'ajax_network_admin' ) ); - add_action( 'network_admin_menu', array( $this->plugin->admin, 'register_menu' ) ); + add_action( 'network_admin_menu', array( $this->plugin->admin->menu, 'register_menu' ) ); add_action( 'network_admin_menu', array( $this, 'admin_menu_screens' ) ); add_action( 'admin_menu', array( $this, 'admin_menu_screens' ) ); add_action( 'admin_bar_menu', array( $this, 'network_admin_bar_menu' ), 99 ); @@ -187,13 +187,13 @@ public function admin_menu_screens() { remove_submenu_page( $this->plugin->admin->records_page_slug, 'wp_stream_settings' ); remove_submenu_page( $this->plugin->admin->records_page_slug, 'edit.php?post_type=wp_stream_alerts' ); - $this->plugin->admin->screen_id['network_settings'] = add_submenu_page( + $this->plugin->admin->menu->screen_id['network_settings'] = add_submenu_page( $this->plugin->admin->records_page_slug, __( 'Stream Network Settings', 'stream' ), __( 'Network Settings', 'stream' ), $this->plugin->admin->settings_cap, $this->network_settings_page_slug, - array( $this->plugin->admin, 'render_settings_page' ) + array( $this->plugin->admin->settings, 'render_settings_page' ) ); } diff --git a/classes/class-settings.php b/classes/class-settings.php index 7280de9e4..1f044b702 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -443,7 +443,7 @@ class_exists( '\WP_Ability' ) * Build the "Reset Stream Database" settings field definition. * * Extracted so the async-deletion running-state check - * ({@see Admin::is_running_async_deletion()}) is evaluated once per render + * ({@see Admin_Purge::is_running_async_deletion()}) is evaluated once per render * instead of once per field property, and only in admin context. * * `Settings::__construct` populates `$this->options = $this->get_options()` @@ -454,7 +454,7 @@ class_exists( '\WP_Ability' ) * @return array */ private function build_delete_all_records_field() { - $is_running_deletion = is_admin() ? Admin::is_running_async_deletion() : false; + $is_running_deletion = is_admin() ? $this->plugin->admin->purge->is_running_async_deletion() : false; return array( 'name' => 'delete_all_records', @@ -478,7 +478,7 @@ private function build_delete_all_records_field() { * Build the "Clean Orphaned Meta" settings field definition. * * Extracted so the auto-purge running-state check - * ({@see Admin::is_running_auto_purge()}) is evaluated once per render + * ({@see Admin_Purge::is_running_auto_purge()}) is evaluated once per render * instead of once per field property, and only in admin context — the * field is never rendered outside admin, so the Action Scheduler query * is skipped on front-end pageloads. @@ -486,7 +486,7 @@ private function build_delete_all_records_field() { * @return array */ private function build_clean_orphan_meta_field() { - $is_running = is_admin() ? Admin::is_running_auto_purge() : false; + $is_running = is_admin() ? $this->plugin->admin->purge->is_running_auto_purge() : false; return array( 'name' => 'clean_orphan_meta', @@ -665,7 +665,7 @@ public function get_defaults() { public function get_deletion_warning( $is_running_deletion = null ): string { if ( null === $is_running_deletion ) { - $is_running_deletion = is_admin() ? Admin::is_running_async_deletion() : false; + $is_running_deletion = is_admin() ? $this->plugin->admin->purge->is_running_async_deletion() : false; } if ( $is_running_deletion ) { @@ -1419,15 +1419,20 @@ public function updated_option_ttl_remove_records( $old_value, $new_value ) { // real chain is already running. Falls back to inline if no // scheduler is available (defensive — Plugin::__construct() sets it). if ( ! empty( $this->plugin->scheduler ) ) { - if ( ! \WP_Stream\Admin::is_running_auto_purge() ) { + // Prefer the purge collaborator when Admin is loaded (is_admin / + // WP-CLI / cron). Without it, skip the overlap probe and still + // enqueue — the recurring callback's own guard covers stacking. + $is_running = isset( $this->plugin->admin->purge ) + && $this->plugin->admin->purge->is_running_auto_purge(); + if ( ! $is_running ) { $this->plugin->scheduler->enqueue_async( \WP_Stream\Admin::AUTO_PURGE_ACTION, array(), \WP_Stream\Admin::AUTO_PURGE_GROUP ); } - } elseif ( isset( $this->plugin->admin ) ) { - $this->plugin->admin->purge_scheduled_action(); + } elseif ( isset( $this->plugin->admin->purge ) ) { + $this->plugin->admin->purge->purge_scheduled_action(); } } } diff --git a/tests/phpunit/Admin_Ajax_Test.php b/tests/phpunit/Admin_Ajax_Test.php new file mode 100644 index 000000000..cbb231a1a --- /dev/null +++ b/tests/phpunit/Admin_Ajax_Test.php @@ -0,0 +1,393 @@ +admin = $this->plugin->admin; + $this->assertNotEmpty( $this->admin ); + $this->ajax = $this->get_admin_collaborator( $this->admin, 'ajax' ); + $this->purge = $this->get_admin_collaborator( $this->admin, 'purge' ); + + $this->admin_user_id = \WP_UnitTestCase_Base::factory()->user->create( + array( + 'role' => 'administrator', + 'user_login' => 'test_admin', + 'email' => 'test@land.com', + ) + ); + wp_set_current_user( $this->admin_user_id ); + } + + public function tearDown(): void { + parent::tear_down(); + + if ( is_multisite() ) { + wpmu_delete_user( $this->admin_user_id ); + } else { + wp_delete_user( $this->admin_user_id ); + } + } + + private function dummy_stream_data() { + return array( + 'object_id' => null, + 'site_id' => '1', + 'blog_id' => get_current_blog_id(), + 'user_id' => '1', + 'user_role' => 'administrator', + 'created' => gmdate( 'Y-m-d H:i:s' ), + 'summary' => '"Hello Dave" plugin activated', + 'ip' => '192.168.0.1', + 'connector' => 'installer', + 'context' => 'plugins', + 'action' => 'activated', + ); + } + + private function dummy_stream_data_other_blog() { + return array( + 'object_id' => null, + 'site_id' => '1', + 'blog_id' => (int) get_current_blog_id() + 1, + 'user_id' => '1', + 'user_role' => 'administrator', + 'created' => gmdate( 'Y-m-d H:i:s' ), + 'summary' => '"Hello Dave" plugin activated', + 'ip' => '192.168.0.1', + 'connector' => 'installer', + 'context' => 'plugins', + 'action' => 'activated', + ); + } + + private function dummy_meta_data( $stream_id ) { + return array( + 'record_id' => $stream_id, + 'meta_key' => 'space_helmet', + 'meta_value' => 'false', + ); + } + + /** + * Insert N stream rows aged $days_old days, optionally pinned to a blog id. + * + * @param int $count Number of rows to insert. + * @param int $days_old How many days ago `created` should be set to. + * @param int|null $blog_id Optional blog id override. + * @return int[] Inserted stream IDs. + */ + private function seed_aged_records( int $count, int $days_old, $blog_id = null ): array { + global $wpdb; + $ids = array(); + for ( $i = 0; $i < $count; $i++ ) { + $row = $this->dummy_stream_data(); + $row['created'] = gmdate( 'Y-m-d H:i:s', strtotime( $days_old . ' days ago' ) ); + if ( null !== $blog_id ) { + $row['blog_id'] = $blog_id; + } + $wpdb->insert( $wpdb->stream, $row ); + $stream_id = (int) $wpdb->insert_id; + $ids[] = $stream_id; + $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $stream_id ) ); + } + return $ids; + } + + /** + * Set the records TTL in whichever option applies on this install. + * + * @param int $days Number of days to retain records for. + */ + private function set_records_ttl( int $days ) { + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + $options = (array) get_site_option( 'wp_stream_network', array() ); + $options['general_records_ttl'] = (string) $days; + unset( $options['general_keep_records_indefinitely'] ); + update_site_option( 'wp_stream_network', $options ); + } else { + $options = (array) get_option( 'wp_stream', array() ); + $options['general_records_ttl'] = (string) $days; + unset( $options['general_keep_records_indefinitely'] ); + update_option( 'wp_stream', $options ); + } + } + + /** + * Also tests private method erase_stream_records + */ + public function test_wp_ajax_reset() { + $_REQUEST['wp_stream_nonce'] = wp_create_nonce( 'stream_nonce' ); + $_REQUEST['wp_stream_nonce_reset'] = wp_create_nonce( 'stream_nonce_reset' ); + + global $wpdb; + + // Create dummy records + $stream_data = $this->dummy_stream_data(); + $wpdb->insert( $wpdb->stream, $stream_data ); + $stream_id = $wpdb->insert_id; + $this->assertNotFalse( $stream_id ); + + // Create dummy meta + $meta_data = $this->dummy_meta_data( $stream_id ); + $wpdb->insert( $wpdb->streammeta, $meta_data ); + $meta_id = $wpdb->insert_id; + $this->assertNotFalse( $meta_id ); + + // Check that records exist + $stream_result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->stream} WHERE ID = %d", $stream_id ) ); + $this->assertNotEmpty( $stream_result ); + + // Check that meta exists + $meta_result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->streammeta} WHERE meta_id = %d", $meta_id ) ); + $this->assertNotEmpty( $meta_result ); + + // Clear records and meta + $reset = $this->ajax->wp_ajax_reset(); + $this->assertTrue( $reset ); + + // Check that records have been cleared + $stream_results = $wpdb->get_results( "SELECT * FROM {$wpdb->stream}" ); + $this->assertEmpty( $stream_results ); + + // Check that meta has been cleared + $meta_results = $wpdb->get_results( "SELECT * FROM {$wpdb->streammeta}" ); + $this->assertEmpty( $meta_results ); + } + + /** + * Also tests private method erase_stream_records + */ + public function test_wp_ajax_reset_large_records_blog() { + + if ( ! is_multisite() ) { + $this->markTestSkipped( 'This test requires multisite.' ); + } + + global $wpdb; + + $_REQUEST['wp_stream_nonce'] = wp_create_nonce( 'stream_nonce' ); + $_REQUEST['wp_stream_nonce_reset'] = wp_create_nonce( 'stream_nonce_reset' ); + + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + add_filter( 'wp_stream_is_network_activated', '__return_false' ); + + $stream_data = $this->dummy_stream_data(); + $wpdb->insert( $wpdb->stream, $stream_data ); + $stream_id = $wpdb->insert_id; + $this->assertNotFalse( $stream_id ); + + $meta_data = $this->dummy_meta_data( $stream_id ); + $wpdb->insert( $wpdb->streammeta, $meta_data ); + $meta_id = $wpdb->insert_id; + $this->assertNotFalse( $meta_id ); + + $stream_data_2 = $this->dummy_stream_data_other_blog(); + $wpdb->insert( $wpdb->stream, $stream_data_2 ); + $stream_id_2 = $wpdb->insert_id; + $this->assertNotFalse( $stream_id_2 ); + + $meta_data = $this->dummy_meta_data( $stream_id_2 ); + $wpdb->insert( $wpdb->streammeta, $meta_data ); + $meta_id_2 = $wpdb->insert_id; + $this->assertNotFalse( $meta_id_2 ); + + // Clear records and meta + $reset = $this->ajax->wp_ajax_reset(); + $this->assertTrue( $reset ); + + $current_blog = (int) get_current_blog_id(); + + // Assert the scheduled action has been set. + $this->assertTrue( + as_has_scheduled_action( + Admin::ASYNC_DELETION_ACTION + ) + ); + + // Check that records have not been cleared yet. + $stream_results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->stream} WHERE blog_id=%d", + $current_blog + ) + ); + $this->assertNotEmpty( $stream_results ); + + $this->purge->erase_large_records( 1, 0, $meta_id, $current_blog ); + + // Check that records have been cleared. + $stream_results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->stream} WHERE blog_id=%d", + $current_blog + ) + ); + $this->assertEmpty( $stream_results ); + + // Check that records of the other blog have not been cleared. + $stream_results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->stream} WHERE blog_id=%d", + $current_blog + 1 + ) + ); + $this->assertNotEmpty( $stream_results ); + + // Check that one meta has been cleared + $meta_results = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->streammeta}" ); + $this->assertEquals( 1, $meta_results ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + remove_filter( 'wp_stream_is_network_activated', '__return_false' ); + } + + /** + * Test Ajax Filters + * + * @group ajax + * @requires PHPUnit 5.7 + */ + public function test_ajax_filters() { + $user = new \WP_User( $this->admin_user_id ); + + $this->_setRole( 'subscriber' ); + + $_POST['filter'] = 'user_id'; + $_POST['q'] = $user->display_name; + $_POST['nonce'] = wp_create_nonce( 'stream_filters_user_search_nonce' ); + + $this->expectException( 'WPAjaxDieStopException' ); + + try { + $this->_handleAjax( 'wp_stream_filters' ); + } catch ( WPAjaxDieStopException $e ) { + // Do nothing. + } + + // Check that the exception was thrown. + $this->assertTrue( isset( $e ) ); + + // The output should be a -1 for failure. + $this->assertEquals( '-1', $e->getMessage() ); + unset( $e ); + + $this->_setRole( 'administrator' ); + + $this->_handleAjax( 'wp_stream_filters' ); + $json = $this->_last_response; + + $this->assertNotEmpty( $json ); + $data = json_decode( $json ); + $this->assertNotFalse( $data ); + $this->assertNotEmpty( $data ); + $this->assertIsArray( $data ); + } + + public function test_ajax_clean_orphan_meta_schedules_reaper() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $_REQUEST['wp_stream_nonce_clean_orphan_meta'] = wp_create_nonce( 'stream_nonce_clean_orphan_meta' ); + + $result = $this->ajax->wp_ajax_clean_orphan_meta(); + $this->assertTrue( $result ); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Ajax handler must enqueue the reaper action' + ); + + unset( $_REQUEST['wp_stream_nonce_clean_orphan_meta'] ); + } + + /** + * Security boundary: a user without WP_STREAM_SETTINGS_CAPABILITY must + * be rejected before the handler reaches the AS enqueue. Mirrors the + * capability check used by the reset/erase handlers in this class. + * + * Uses _handleAjax() so WP_Ajax_UnitTestCase's output-buffer machinery + * runs (the handler calls wp_die(), which the testcase die handler + * routes through ob_get_clean()); calling the method directly would + * leave the buffer state ambiguous and PHPUnit would mark the test risky. + * + * @throws \WPAjaxDieStopException Thrown by the testcase die handler when + * the rejected request triggers wp_die(). + */ + public function test_ajax_clean_orphan_meta_denies_users_without_settings_cap() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $_REQUEST['wp_stream_nonce_clean_orphan_meta'] = wp_create_nonce( 'stream_nonce_clean_orphan_meta' ); + + $this->expectException( \WPAjaxDieStopException::class ); + + try { + $this->_handleAjax( 'wp_stream_clean_orphan_meta' ); + } catch ( \WPAjaxDieStopException $e ) { + unset( $_REQUEST['wp_stream_nonce_clean_orphan_meta'] ); + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'No work must be enqueued for a rejected request' + ); + throw $e; + } + } + + public function test_get_users_record_meta() { + $user_id = $this->admin_user_id; + $authors = array( + $user_id => get_user_by( 'id', $user_id ), + ); + + $records = $this->ajax->get_users_record_meta( $authors ); + + $this->assertArrayHasKey( $user_id, $records ); + $this->assertArrayHasKey( 'text', $records[ $user_id ] ); + $this->assertEquals( 'test_admin', $records[ $user_id ]['text'] ); + } +} diff --git a/tests/phpunit/Admin_Assets_Test.php b/tests/phpunit/Admin_Assets_Test.php new file mode 100644 index 000000000..a9661c750 --- /dev/null +++ b/tests/phpunit/Admin_Assets_Test.php @@ -0,0 +1,128 @@ +admin = $this->plugin->admin; + $this->assertNotEmpty( $this->admin ); + $this->assets = $this->get_admin_collaborator( $this->admin, 'assets' ); + + $admin_user_id = \WP_UnitTestCase_Base::factory()->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( $admin_user_id ); + + // Populate screen_id so enqueue tests can target a Stream screen hook. + global $menu; + $menu = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + do_action( 'admin_menu' ); + } + + public function test_admin_enqueue_scripts() { + global $wp_scripts; + + $this->assertNotEmpty( $this->admin->menu->screen_id['main'] ); + + // Non-Stream screen + $this->assets->admin_enqueue_scripts( 'edit.php' ); + + $this->assertFalse( wp_script_is( 'wp-stream-admin' ), 'wp-stream-admin script is not enqueued' ); + $this->assertFalse( wp_style_is( 'wp-stream-admin' ), 'wp-stream-admin style is not enqueued' ); + + $this->assertTrue( wp_script_is( 'wp-stream-global' ), 'wp-stream-global script is enqueued' ); + + $this->assertStringContainsString( + 'bulk_actions', + $wp_scripts->get_inline_script_data( 'wp-stream-global', 'before' ), + ); + + // Stream screen + $this->assets->admin_enqueue_scripts( $this->plugin->admin->menu->screen_id['main'] ); + + $this->assertTrue( wp_style_is( 'wp-stream-admin' ), 'wp-stream-admin style is enqueued' ); + + $this->assertTrue( wp_script_is( 'wp-stream-select2' ), 'wp-stream-select2 script is enqueued' ); + $this->assertTrue( wp_script_is( 'wp-stream-select2-en' ), 'wp-stream-select2-en script is enqueued' ); + $this->assertTrue( wp_script_is( 'wp-stream-jquery-timeago' ), 'wp-stream-jquery-timeago script is enqueued' ); + $this->assertTrue( wp_script_is( 'wp-stream-jquery-timeago-en' ), 'wp-stream-jquery-timeago-en script is enqueued' ); + + $this->assertTrue( wp_script_is( 'wp-stream-admin' ), 'wp-stream-admin script is enqueued' ); + $this->assertTrue( wp_script_is( 'wp-stream-live-updates' ), 'wp-stream-live-updates script is enqueued' ); + + $this->assertStringContainsString( + 'i18n', + $wp_scripts->get_inline_script_data( 'wp-stream-admin', 'before' ), + ); + + $this->assertStringContainsString( + 'current_screen', + $wp_scripts->get_inline_script_data( 'wp-stream-live-updates', 'before' ), + ); + $this->assertStringContainsString( + $this->plugin->admin->menu->screen_id['main'], + $wp_scripts->get_inline_script_data( 'wp-stream-live-updates', 'before' ), + ); + } + + public function test_is_stream_screen() { + $this->assertFalse( $this->assets->is_stream_screen() ); + + if ( ! defined( 'WP_ADMIN' ) ) { + define( 'WP_ADMIN', true ); + } + $_GET['page'] = $this->admin->records_page_slug; + + $this->assertTrue( $this->assets->is_stream_screen() ); + } + + public function test_admin_body_class() { + // Make this the Stream screen + if ( ! defined( 'WP_ADMIN' ) ) { + define( 'WP_ADMIN', true ); + } + $_GET['page'] = $this->admin->records_page_slug; + + $classes = 'sit-down-calmy take-a-stress-pill think-things-over'; + $admin_body_classes = $this->assets->admin_body_class( $classes ); + + $this->assertStringContainsString( 'think-things-over ', $admin_body_classes ); + $this->assertStringContainsString( $this->admin->admin_body_class . ' ', $admin_body_classes ); + $this->assertStringContainsString( $this->admin->records_page_slug . ' ', $admin_body_classes ); + } + + public function test_admin_menu_css() { + global $wp_styles; + + $this->assets->admin_menu_css(); + + $dependency = $wp_styles->registered['wp-admin']; + $this->assertArrayHasKey( 'after', $dependency->extra ); + $this->assertNotEmpty( $dependency->extra['after'] ); + $this->assertStringContainsString( "body.{$this->admin->admin_body_class}", $dependency->extra['after'][0] ); + } +} diff --git a/tests/phpunit/Admin_Cron_Purge_Test.php b/tests/phpunit/Admin_Cron_Purge_Test.php index acd72a10e..3ffcb4ce5 100644 --- a/tests/phpunit/Admin_Cron_Purge_Test.php +++ b/tests/phpunit/Admin_Cron_Purge_Test.php @@ -23,6 +23,13 @@ class Admin_Cron_Purge_Test extends WP_StreamTestCase { */ protected $admin; + /** + * Purge collaborator under test. + * + * @var Admin_Purge + */ + protected $purge; + /** * Scheduler that was active before this test swapped in the cron one. * @@ -35,10 +42,11 @@ public function setUp(): void { $this->admin = $this->plugin->admin; $this->assertNotEmpty( $this->admin ); + $this->purge = $this->get_admin_collaborator( $this->admin, 'purge' ); // Force the WP-Cron fallback for the duration of each test. Because - // $this->plugin is the global instance, this also routes the static - // is_running_* probes through the cron scheduler. + // $this->plugin is the same instance Admin_Purge reads via + // $this->admin->plugin, is_running_* probes use the cron scheduler. $this->original_scheduler = $this->plugin->scheduler; $this->plugin->scheduler = new Cron_Scheduler(); @@ -150,7 +158,7 @@ public function test_schedule_setup_registers_recurring_cron_event() { wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' ); $this->assertNotFalse( wp_next_scheduled( 'wp_stream_auto_purge' ) ); - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); $this->assertFalse( wp_next_scheduled( 'wp_stream_auto_purge' ), @@ -168,7 +176,7 @@ public function test_schedule_setup_registers_recurring_cron_event() { // Idempotent: a second call must not stack a duplicate. $first = wp_next_scheduled( Admin::AUTO_PURGE_ACTION ); - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); $this->assertSame( $first, wp_next_scheduled( Admin::AUTO_PURGE_ACTION ) ); } @@ -182,7 +190,7 @@ public function test_small_table_fast_path_deletes_inline_and_enqueues_reaper() $ids = $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); - $this->admin->purge_scheduled_action(); + $this->purge->purge_scheduled_action(); $remaining = (int) $wpdb->get_var( $wpdb->prepare( @@ -212,7 +220,7 @@ public function test_large_table_schedules_batch_chain() { $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); - $this->admin->purge_scheduled_action(); + $this->purge->purge_scheduled_action(); $this->assertTrue( $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_BATCH_ACTION ), @@ -243,7 +251,7 @@ function () { $this->seed_aged_records( 5, 5 ); $before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); - $this->admin->auto_purge_batch( $this->cutoff_one_day_ago(), 0 ); + $this->purge->auto_purge_batch( $this->cutoff_one_day_ago(), 0 ); $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); $this->assertLessThan( $before, $remaining, 'Batch must delete at least one row' ); @@ -266,7 +274,7 @@ public function test_batch_enqueues_reaper_and_clears_marker_when_done() { $wpdb->query( "DELETE FROM {$wpdb->stream}" ); $wpdb->query( "DELETE FROM {$wpdb->streammeta}" ); - $this->admin->auto_purge_batch( $this->cutoff_one_day_ago(), 0 ); + $this->purge->auto_purge_batch( $this->cutoff_one_day_ago(), 0 ); $this->assertFalse( $this->plugin->scheduler->has_scheduled( Admin::AUTO_PURGE_BATCH_ACTION ), @@ -282,7 +290,7 @@ public function test_batch_enqueues_reaper_and_clears_marker_when_done() { ); // The reaper clears the marker when it finishes. - $this->admin->auto_purge_reaper(); + $this->purge->auto_purge_reaper(); $this->assertFalse( (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ), 'Running marker must be cleared once the reaper completes' @@ -296,14 +304,14 @@ public function test_batch_enqueues_reaper_and_clears_marker_when_done() { */ public function test_enable_auto_purge_filter_disables_scheduling() { // Establish a recurring purge first. - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); $this->assertNotFalse( wp_next_scheduled( Admin::AUTO_PURGE_ACTION ), 'Recurring purge must be scheduled before the disable filter is applied' ); add_filter( 'wp_stream_enable_auto_purge', '__return_false' ); - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); $this->assertFalse( wp_next_scheduled( Admin::AUTO_PURGE_ACTION ), @@ -318,7 +326,7 @@ public function test_enable_auto_purge_filter_disables_scheduling() { // Re-enabling must recover: the sentinel differs from the active // backend, so the switch cleanup re-registers the recurring purge. remove_all_filters( 'wp_stream_enable_auto_purge' ); - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); $this->assertNotFalse( wp_next_scheduled( Admin::AUTO_PURGE_ACTION ), 'Re-enabling auto-purge must re-register the recurring event' @@ -337,7 +345,7 @@ public function test_enable_auto_purge_filter_blocks_executing_purge() { $this->set_records_ttl( 1 ); add_filter( 'wp_stream_enable_auto_purge', '__return_false' ); - $this->admin->purge_scheduled_action(); + $this->purge->purge_scheduled_action(); remove_all_filters( 'wp_stream_enable_auto_purge' ); $remaining = (int) $wpdb->get_var( @@ -366,7 +374,7 @@ public function test_large_table_on_cron_persists_and_renders_admin_notice() { $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); - $this->admin->purge_scheduled_action(); + $this->purge->purge_scheduled_action(); $stored = get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); $this->assertNotEmpty( @@ -387,7 +395,7 @@ public function test_large_table_on_cron_persists_and_renders_admin_notice() { } ob_start(); - $this->admin->display_large_table_cron_notice(); + $this->purge->display_large_table_cron_notice(); $rendered = ob_get_clean(); $this->assertStringContainsString( @@ -401,7 +409,7 @@ public function test_large_table_on_cron_persists_and_renders_admin_notice() { ); ob_start(); - $this->admin->display_large_table_cron_notice(); + $this->purge->display_large_table_cron_notice(); $second = ob_get_clean(); $this->assertEmpty( $second, 'The warning must render only once' ); @@ -418,7 +426,7 @@ public function test_large_table_cron_notice_requires_capability() { wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); ob_start(); - $this->admin->display_large_table_cron_notice(); + $this->purge->display_large_table_cron_notice(); $rendered = ob_get_clean(); $this->assertEmpty( $rendered, 'Users without the settings capability must not see the warning' ); @@ -443,7 +451,7 @@ public function test_large_table_notice_suppressed_when_auto_purge_disabled() { $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); - $this->admin->purge_scheduled_action(); + $this->purge->purge_scheduled_action(); $this->assertEmpty( get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ), @@ -466,9 +474,10 @@ public function test_reset_warning_not_suppressed_by_auto_purge_filter() { delete_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); - $method = new \ReflectionMethod( Admin::class, 'maybe_warn_large_table_without_action_scheduler' ); - $method->setAccessible( true ); - $method->invoke( $this->admin, 2000000, 'reset the Stream database (delete all records for this site)' ); + $this->purge->maybe_warn_large_table_without_action_scheduler( + 2000000, + 'reset the Stream database (delete all records for this site)' + ); $stored = get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ); $this->assertNotEmpty( @@ -499,7 +508,7 @@ public function test_large_table_on_action_scheduler_does_not_warn() { $this->seed_aged_records( 2, 5 ); $this->set_records_ttl( 1 ); - $this->admin->purge_scheduled_action(); + $this->purge->purge_scheduled_action(); $this->assertEmpty( get_option( Admin::LARGE_TABLE_CRON_NOTICE_OPTION ), @@ -528,28 +537,28 @@ function () { $last = max( $ids ); // Non-terminal batch: marker set, next batch chained. - $this->admin->erase_large_records( 5, 0, $last, get_current_blog_id() ); + $this->purge->erase_large_records( 5, 0, $last, get_current_blog_id() ); $this->assertTrue( (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ), 'Running marker must be set while the reset chain is mid-flight' ); $this->assertTrue( - Admin::is_running_async_deletion(), + $this->purge->is_running_async_deletion(), 'is_running_async_deletion() must read busy while the chain is pending' ); // Drain: run remaining batches directly until the terminal one. $wpdb->query( "DELETE FROM {$wpdb->stream}" ); wp_unschedule_hook( Admin::ASYNC_DELETION_ACTION ); - $this->admin->erase_large_records( 5, 5, $last, get_current_blog_id() ); + $this->purge->erase_large_records( 5, 5, $last, get_current_blog_id() ); $this->assertFalse( (bool) get_transient( Cron_Scheduler::RUNNING_TRANSIENT ), 'Terminal batch must clear the running marker' ); $this->assertFalse( - Admin::is_running_async_deletion(), + $this->purge->is_running_async_deletion(), 'is_running_async_deletion() must read idle after the chain completes' ); @@ -562,7 +571,7 @@ function () { */ public function test_is_running_auto_purge_reflects_cron_state() { $this->assertFalse( - Admin::is_running_auto_purge(), + $this->purge->is_running_auto_purge(), 'Guard must read idle when nothing is scheduled or running' ); @@ -575,10 +584,10 @@ function () { $this->seed_aged_records( 5, 5 ); // First batch deletes a window and chains the next batch. - $this->admin->auto_purge_batch( $this->cutoff_one_day_ago(), 0 ); + $this->purge->auto_purge_batch( $this->cutoff_one_day_ago(), 0 ); $this->assertTrue( - Admin::is_running_auto_purge(), + $this->purge->is_running_auto_purge(), 'Guard must read busy while a batch chain is pending on WP-Cron' ); diff --git a/tests/phpunit/Admin_Menu_Test.php b/tests/phpunit/Admin_Menu_Test.php new file mode 100644 index 000000000..51311ed35 --- /dev/null +++ b/tests/phpunit/Admin_Menu_Test.php @@ -0,0 +1,84 @@ +admin = $this->plugin->admin; + $this->assertNotEmpty( $this->admin ); + $this->menu = $this->get_admin_collaborator( $this->admin, 'menu' ); + + $this->admin_user_id = \WP_UnitTestCase_Base::factory()->user->create( + array( + 'role' => 'administrator', + 'user_login' => 'test_admin_menu', + 'email' => 'test-menu@land.com', + ) + ); + wp_set_current_user( $this->admin_user_id ); + } + + public function tearDown(): void { + parent::tear_down(); + + if ( is_multisite() ) { + wpmu_delete_user( $this->admin_user_id ); + } else { + wp_delete_user( $this->admin_user_id ); + } + } + + public function test_register_menu() { + global $menu; + $menu = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + do_action( 'admin_menu' ); + + $this->assertNotEmpty( $this->menu->screen_id ); + $this->assertNotEmpty( $this->menu->screen_id['main'] ); + $this->assertNotEmpty( $this->menu->screen_id['settings'] ); + } + + /** + * Network registers network_admin_menu → Admin_Menu::register_menu(). + */ + public function test_network_admin_menu_uses_menu_collaborator() { + global $menu; + $menu = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $this->menu->screen_id = array(); + $this->menu->register_menu(); + + $this->assertNotEmpty( $this->menu->screen_id['main'] ); + $this->assertNotEmpty( $this->menu->screen_id['settings'] ); + } +} diff --git a/tests/phpunit/Admin_Purge_Test.php b/tests/phpunit/Admin_Purge_Test.php new file mode 100644 index 000000000..5f14a9d4e --- /dev/null +++ b/tests/phpunit/Admin_Purge_Test.php @@ -0,0 +1,923 @@ +admin = $this->plugin->admin; + $this->assertNotEmpty( $this->admin ); + $this->purge = $this->get_admin_collaborator( $this->admin, 'purge' ); + self::$bc_action_hits = 0; + } + + /** + * Named callable for wp_stream_auto_purge hit counting. + * + * @return void + */ + public static function count_bc_action_hit(): void { + ++self::$bc_action_hits; + } + + /** + * Named filter returning batch size 2. + * + * @return int + */ + public static function filter_batch_size_two(): int { + return 2; + } + + /** + * Named filter returning batch size 3. + * + * @return int + */ + public static function filter_batch_size_three(): int { + return 3; + } + + private function dummy_stream_data() { + return array( + 'object_id' => null, + 'site_id' => '1', + 'blog_id' => get_current_blog_id(), + 'user_id' => '1', + 'user_role' => 'administrator', + 'created' => gmdate( 'Y-m-d H:i:s' ), + 'summary' => '"Hello Dave" plugin activated', + 'ip' => '192.168.0.1', + 'connector' => 'installer', + 'context' => 'plugins', + 'action' => 'activated', + ); + } + + private function dummy_stream_data_other_blog() { + return array( + 'object_id' => null, + 'site_id' => '1', + 'blog_id' => (int) get_current_blog_id() + 1, + 'user_id' => '1', + 'user_role' => 'administrator', + 'created' => gmdate( 'Y-m-d H:i:s' ), + 'summary' => '"Hello Dave" plugin activated', + 'ip' => '192.168.0.1', + 'connector' => 'installer', + 'context' => 'plugins', + 'action' => 'activated', + ); + } + + private function dummy_meta_data( $stream_id ) { + return array( + 'record_id' => $stream_id, + 'meta_key' => 'space_helmet', + 'meta_value' => 'false', + ); + } + + /** + * Insert N stream rows aged $days_old days, optionally pinned to a blog id. + * + * @param int $count Number of rows to insert. + * @param int $days_old How many days ago `created` should be set to. + * @param int|null $blog_id Optional blog id override. + * @return int[] Inserted stream IDs. + */ + private function seed_aged_records( int $count, int $days_old, $blog_id = null ): array { + global $wpdb; + $ids = array(); + for ( $i = 0; $i < $count; $i++ ) { + $row = $this->dummy_stream_data(); + $row['created'] = gmdate( 'Y-m-d H:i:s', strtotime( $days_old . ' days ago' ) ); + if ( null !== $blog_id ) { + $row['blog_id'] = $blog_id; + } + $wpdb->insert( $wpdb->stream, $row ); + $stream_id = (int) $wpdb->insert_id; + $ids[] = $stream_id; + $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $stream_id ) ); + } + return $ids; + } + + /** + * Set the records TTL in whichever option applies on this install. + * + * @param int $days Number of days to retain records for. + */ + private function set_records_ttl( int $days ) { + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + $options = (array) get_site_option( 'wp_stream_network', array() ); + $options['general_records_ttl'] = (string) $days; + unset( $options['general_keep_records_indefinitely'] ); + update_site_option( 'wp_stream_network', $options ); + } else { + $options = (array) get_option( 'wp_stream', array() ); + $options['general_records_ttl'] = (string) $days; + unset( $options['general_keep_records_indefinitely'] ); + update_option( 'wp_stream', $options ); + } + } + + public function test_purge_schedule_setup_uses_action_scheduler_and_unschedules_wp_cron() { + // Simulate a pre-existing legacy WP-Cron event from older Stream versions. + wp_clear_scheduled_hook( 'wp_stream_auto_purge' ); + wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' ); + $this->assertNotFalse( wp_next_scheduled( 'wp_stream_auto_purge' ) ); + + // Make sure AS has no purge actions queued. + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); + } + + $this->purge->purge_schedule_setup(); + + // Legacy WP-Cron event is gone. + $this->assertFalse( + wp_next_scheduled( 'wp_stream_auto_purge' ), + 'Legacy wp_stream_auto_purge WP-Cron event should be cleared' + ); + + // Recurring AS action is scheduled. + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_ACTION ), + 'Recurring AS auto-purge action should be scheduled' + ); + + // Idempotent: calling it again must not schedule a second recurring action. + $this->purge->purge_schedule_setup(); + $ids = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertCount( 1, $ids, 'purge_schedule_setup() must be idempotent' ); + } + + public function test_purge_scheduled_action_fires_bc_action_once_when_work_runs() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + self::$bc_action_hits = 0; + add_action( 'wp_stream_auto_purge', array( self::class, 'count_bc_action_hit' ) ); + + // Make sure something is eligible so we exercise the full code path. + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + $this->purge->purge_scheduled_action(); + + remove_action( 'wp_stream_auto_purge', array( self::class, 'count_bc_action_hit' ) ); + $this->assertSame( 1, self::$bc_action_hits, 'wp_stream_auto_purge action must fire exactly once per recurring tick when work runs' ); + } + + public function test_purge_scheduled_action_does_not_fire_bc_action_when_cycle_bails() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + // keep_records_indefinitely=1 is one of the bail-out conditions. + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + update_site_option( 'wp_stream_network', array( 'general_keep_records_indefinitely' => 1 ) ); + } else { + update_option( 'wp_stream', array( 'general_keep_records_indefinitely' => 1 ) ); + } + + self::$bc_action_hits = 0; + add_action( 'wp_stream_auto_purge', array( self::class, 'count_bc_action_hit' ) ); + + $this->purge->purge_scheduled_action(); + + remove_action( 'wp_stream_auto_purge', array( self::class, 'count_bc_action_hit' ) ); + $this->assertSame( + 0, + self::$bc_action_hits, + 'wp_stream_auto_purge BC action must not fire when the cycle bails out (keep_records_indefinitely)' + ); + } + + public function test_purge_scheduled_action_small_table_fast_path() { + // Default: table is "small" (filter returns false for record_count <= 1M). + global $wpdb; + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + $ids = $this->seed_aged_records( 2, 5 ); + $this->set_records_ttl( 1 ); + + $this->purge->purge_scheduled_action(); + + // Inline DELETE must have run — rows are gone. + $remaining = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->stream} WHERE ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', + ...$ids + ) + ); + $this->assertSame( 0, $remaining, 'Small-table fast path must delete eligible rows inline' ); + + // No batched chain was enqueued. + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Small-table fast path must not enqueue a batched chain' + ); + + // Reaper still runs so the heal step is observable in Scheduled Actions. + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Small-table fast path must still enqueue the orphan reaper' + ); + } + + public function test_purge_scheduled_action_large_table_uses_batched_chain() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + // Force the "large table" branch without seeding 1M rows. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->seed_aged_records( 2, 5 ); + $this->set_records_ttl( 1 ); + + $this->purge->purge_scheduled_action(); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Large table must enqueue the batched chain' + ); + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Reaper is enqueued by the terminal batch worker, not by the recurring callback' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_purge_scheduled_action_enqueues_first_batch_with_snapshotted_cutoff() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + + // Force the batched path so we can assert batch args. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + $this->purge->purge_scheduled_action(); + + $scheduled = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ) + ); + $this->assertNotEmpty( $scheduled, 'A first batch must be enqueued when records are eligible' ); + + $action = array_shift( $scheduled ); + $args = $action->get_args(); + $this->assertArrayHasKey( 'cutoff', $args ); + $this->assertArrayHasKey( 'blog_id', $args ); + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', + $args['cutoff'], + 'Cutoff must be a MySQL DATETIME string' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_purge_scheduled_action_respects_keep_indefinitely() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + $this->seed_aged_records( 1, 5 ); + + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + update_site_option( 'wp_stream_network', array( 'general_keep_records_indefinitely' => 1 ) ); + } else { + update_option( 'wp_stream', array( 'general_keep_records_indefinitely' => 1 ) ); + } + + $this->purge->purge_scheduled_action(); + + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'No batch must be enqueued when keep-records-indefinitely is on' + ); + } + + public function test_purge_scheduled_action_applies_defaults_when_option_missing() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + // Drop the option entirely. + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + delete_site_option( 'wp_stream_network' ); + } else { + delete_option( 'wp_stream' ); + } + + // Force the batched path so the assertion targets a batch enqueue. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + // Seed records older than the default 30-day TTL. + $this->seed_aged_records( 1, 31 ); + + $this->purge->purge_scheduled_action(); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Defaults (30-day TTL) must apply when the settings option is missing' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_purge_scheduled_action_overlap_guard_skips_when_batch_already_pending() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + // Overlap guard only applies to the batched chain path. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + // First call enqueues a batch. + $this->purge->purge_scheduled_action(); + $first = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertCount( 1, $first ); + + // Second call must be a no-op. + $this->purge->purge_scheduled_action(); + $second = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertCount( 1, $second, 'Overlap guard must prevent stacking a second batch chain' ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_purge_scheduled_action_overlap_guard_skips_when_reaper_pending() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + // Simulate the post-chain state: only the reaper is left pending. + as_enqueue_async_action( + \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, + array(), + \WP_Stream\Admin::AUTO_PURGE_GROUP + ); + + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + $this->purge->purge_scheduled_action(); + + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Overlap guard must skip when only the reaper is pending' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_purge_scheduled_action_bails_when_ttl_is_zero_or_negative() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->seed_aged_records( 1, 5 ); + + // TTL=0 (operator error via CLI/SQL). Must not delete anything. + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + update_site_option( 'wp_stream_network', array( 'general_records_ttl' => '0' ) ); + } else { + update_option( 'wp_stream', array( 'general_records_ttl' => '0' ) ); + } + + $this->purge->purge_scheduled_action(); + + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Non-positive TTL must short-circuit the recurring callback' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_settings_ttl_shortened_triggers_immediate_purge() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $this->seed_aged_records( 1, 5 ); + + // Simulate the option-changed event: TTL shortened from 30 to 7. + $this->plugin->settings->updated_option_ttl_remove_records( + array( 'general_records_ttl' => 30 ), + array( 'general_records_ttl' => 7 ) + ); + + // The TTL-shortened path enqueues the recurring AS action as a + // one-shot async action so work serializes through AS rather than + // running inline (which would bypass the overlap guard). + $async = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertNotEmpty( + $async, + 'Shortening TTL must enqueue an immediate auto-purge action via Action Scheduler' + ); + } + + /** + * Exercises the full hook wiring for the TTL-shortened path: writes the + * option via update_option() / update_site_option() and asserts the AS + * enqueue happened. The unit test above invokes the handler directly, + * which would still pass if the underlying hook registration regressed + * (e.g. someone removed Network::updated_option_ttl_remove_records() + * that bridges update_site_option_wp_stream_network → Settings). + * + * Branches by CI lane: single-site lane fires update_option('wp_stream'); + * multisite (network-activated) lane fires update_site_option('wp_stream_network'). + * Both must end up enqueuing AUTO_PURGE_ACTION. + */ + public function test_settings_ttl_shortened_via_option_update_enqueues_purge() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + // Seed a baseline value of 30 days, then shorten to 7. Both writes + // go through the real WP hook chain (update_option_* or + // update_site_option_*), which is the wiring under test. + $this->set_records_ttl( 30 ); + + // Clear anything the baseline write may have enqueued so the + // assertion below targets the shortening event only. + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); + } + + $this->set_records_ttl( 7 ); + + $async = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertNotEmpty( + $async, + 'Updating the TTL via the option API must enqueue AUTO_PURGE_ACTION through the registered hooks' + ); + } + + public function test_auto_purge_reaper_deletes_orphaned_meta_only() { + global $wpdb; + + // Seed a real record with meta, then a free-floating meta row pointing at + // a non-existent record_id. + $stream_data = $this->dummy_stream_data(); + $stream_data['created'] = gmdate( 'Y-m-d H:i:s', strtotime( '5 days ago' ) ); + $wpdb->insert( $wpdb->stream, $stream_data ); + $real_id = (int) $wpdb->insert_id; + $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $real_id ) ); + + // Orphan meta: record_id points nowhere. + $orphan_record_id = $real_id + 999999; + $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $orphan_record_id ) ); + + $before_orphans = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $orphan_record_id ) + ); + $this->assertSame( 1, $before_orphans ); + + $this->purge->auto_purge_reaper(); + + $after_orphans = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $orphan_record_id ) + ); + $linked_meta = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $real_id ) + ); + + $this->assertSame( 0, $after_orphans, 'Reaper must delete meta rows whose parent stream row is absent' ); + $this->assertSame( 1, $linked_meta, 'Reaper must not touch meta rows whose parent still exists' ); + } + + public function test_auto_purge_batch_deletes_window_and_chains_next_batch() { + global $wpdb; + + // Force a small batch size so we can chain twice without seeding huge data. + add_filter( 'wp_stream_batch_size', array( self::class, 'filter_batch_size_two' ) ); + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + // Seed 5 aged rows. With batch_size=2 the chain runs 3 batches + reaper. + $this->seed_aged_records( 5, 5 ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + $before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); + + $this->purge->auto_purge_batch( $cutoff, 0 ); + + $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); + $this->assertLessThan( $before, $remaining, 'Batch must delete at least one row' ); + $this->assertGreaterThan( 0, $remaining, 'Batch must not delete more than one window of rows' ); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Next batch must be chained when more eligible rows remain' + ); + + remove_all_filters( 'wp_stream_batch_size' ); + } + + public function test_auto_purge_batch_throws_on_empty_cutoff() { + $this->expectException( \InvalidArgumentException::class ); + $this->purge->auto_purge_batch( '', 0, 0 ); + } + + public function test_auto_purge_batch_enqueues_reaper_when_no_rows_remain() { + global $wpdb; + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + // Wipe any leftover rows from earlier tests so nothing is eligible. + $wpdb->query( "DELETE FROM {$wpdb->stream}" ); + $wpdb->query( "DELETE FROM {$wpdb->streammeta}" ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + $this->purge->auto_purge_batch( $cutoff, 0 ); + + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'No further batch must be chained when nothing is eligible' + ); + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Reaper must be enqueued as the terminal step of the chain' + ); + } + + public function test_auto_purge_batch_chain_strides_down_by_window() { + global $wpdb; + + // Force a small batch size so we can chain multiple times. + add_filter( 'wp_stream_batch_size', array( self::class, 'filter_batch_size_three' ) ); + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $ids = $this->seed_aged_records( 4, 5 ); + sort( $ids ); + $top_id = end( $ids ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + // First batch (last_entry=0) should pick the highest ID and pass + // last_entry = top_id - batch_size to the next batch. + $this->purge->auto_purge_batch( $cutoff, 0, 0 ); + + $pending = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ) + ); + $this->assertNotEmpty( $pending ); + $next_args = array_shift( $pending )->get_args(); + + $this->assertArrayHasKey( 'last_entry', $next_args ); + $this->assertSame( + max( 0, $top_id - 3 ), + (int) $next_args['last_entry'], + 'Next batch must receive last_entry = top_id - batch_size' + ); + + remove_all_filters( 'wp_stream_batch_size' ); + } + + /** + * Acceptance criterion: "Per-site activations only purge the current blog." + * + * The batch worker scoping is covered above + * ({@see test_auto_purge_batch_scopes_to_blog_id_when_non_zero}); this + * test closes the gap on the routing decision in + * {@see Admin_Purge::purge_scheduled_action()}: + * + * $blog_id = $this->plugin->is_multisite_not_network_activated() + * ? (int) get_current_blog_id() + * : 0; + * + * Forces is_multisite_not_network_activated() to return true via a Plugin + * stub (CI's multisite lane runs with network-activated = true), then + * asserts the enqueued batch carries the current blog_id rather than 0. + */ + public function test_purge_scheduled_action_scopes_to_current_blog_when_not_network_activated() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Per-site scoping is multisite-only' ); + } + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + + // Force the batched path so the assertion can read $args['blog_id']. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $current_blog = (int) get_current_blog_id(); + $this->seed_aged_records( 1, 5, $current_blog ); + $this->set_records_ttl( 1 ); + + // Swap in a Plugin stub that reports per-site activation. + $real_plugin = $this->admin->plugin; + $stub = new class( $real_plugin ) { + public $settings; + public $db; + public $locations; + public $admin; + public $connectors; + public $scheduler; + public function __construct( $real ) { + $this->settings = $real->settings; + $this->db = $real->db; + $this->locations = $real->locations; + $this->admin = $real->admin; + $this->connectors = $real->connectors; + $this->scheduler = $real->scheduler; + } + public function is_multisite_not_network_activated() { + return true; + } + public function is_multisite_network_activated() { + return false; + } + public function is_large_records_table( int $n ): bool { + return apply_filters( 'wp_stream_is_large_records_table', $n > 1000000, $n ); + } + public function __call( $name, $args ) { + return call_user_func_array( array( $this->settings->plugin ?? null, $name ), $args ); + } + }; + $this->admin->plugin = $stub; + + try { + $this->purge->purge_scheduled_action(); + + $scheduled = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ) + ); + $this->assertNotEmpty( + $scheduled, + 'Per-site activation must still enqueue a batch when records are eligible' + ); + + $action = array_shift( $scheduled ); + $args = $action->get_args(); + $this->assertSame( + $current_blog, + (int) $args['blog_id'], + 'Per-site activation must scope the batch to the current blog (not 0 / all blogs)' + ); + } finally { + $this->admin->plugin = $real_plugin; + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + }//end try + } + + public function test_auto_purge_batch_scopes_to_blog_id_when_non_zero() { + global $wpdb; + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite scoping test' ); + } + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $current_blog = (int) get_current_blog_id(); + $other_blog = $current_blog + 1000; + // arbitrary distinct id, no real blog required for SQL scoping. + + $this->seed_aged_records( 1, 5, $current_blog ); + $this->seed_aged_records( 1, 5, $other_blog ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + $this->purge->auto_purge_batch( $cutoff, $current_blog ); + + $remaining_other = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->stream} WHERE blog_id = %d", $other_blog ) + ); + $this->assertSame( 1, $remaining_other, 'Per-blog scoping must leave sibling blogs untouched' ); + } + + public function test_register_hooks_auto_purge_action_scheduler_callbacks() { + // The Admin instance is constructed by the test bootstrap, so register() + // has already run. Just assert the actions are wired up. + $this->assertNotFalse( + has_action( \WP_Stream\Admin::AUTO_PURGE_ACTION, array( $this->purge, 'purge_scheduled_action' ) ), + 'Recurring auto-purge AS callback should be registered' + ); + $this->assertNotFalse( + has_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, array( $this->purge, 'auto_purge_batch' ) ), + 'Auto-purge batch worker should be registered' + ); + $this->assertNotFalse( + has_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, array( $this->purge, 'auto_purge_reaper' ) ), + 'Auto-purge reaper should be registered' + ); + $this->assertFalse( + has_action( 'wp_stream_auto_purge', array( $this->purge, 'purge_scheduled_action' ) ), + 'Legacy wp_stream_auto_purge hook should no longer dispatch to purge_scheduled_action directly' + ); + } + + public function test_is_running_auto_purge_reflects_chain_state() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( Admin::AUTO_PURGE_REAPER_ACTION ); + } + $this->assertFalse( + $this->purge->is_running_auto_purge(), + 'No scheduled actions means not running' + ); + + as_enqueue_async_action( + Admin::AUTO_PURGE_BATCH_ACTION, + array( + 'cutoff' => '2020-01-01 00:00:00', + 'blog_id' => 0, + 'last_entry' => 0, + ), + Admin::AUTO_PURGE_GROUP + ); + $this->assertTrue( + $this->purge->is_running_auto_purge(), + 'A pending batch action means running' + ); + + as_unschedule_all_actions( Admin::AUTO_PURGE_BATCH_ACTION ); + as_enqueue_async_action( + Admin::AUTO_PURGE_REAPER_ACTION, + array(), + Admin::AUTO_PURGE_GROUP + ); + $this->assertTrue( + $this->purge->is_running_auto_purge(), + 'A pending reaper action means running' + ); + + as_unschedule_all_actions( Admin::AUTO_PURGE_REAPER_ACTION ); + $this->assertFalse( + $this->purge->is_running_auto_purge(), + 'Chain drained: not running' + ); + } + + public function test_is_running_auto_purge_includes_in_progress_actions() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( Admin::AUTO_PURGE_REAPER_ACTION ); + } + + // Enqueue and then flip the action's status to IN-PROGRESS to simulate + // the runner having dequeued an action and started executing it. + // Without RUNNING-aware filtering, is_running_auto_purge() would + // return false here and the overlap guard would let a second chain + // stack against the same rows. + $action_id = as_enqueue_async_action( + Admin::AUTO_PURGE_BATCH_ACTION, + array( + 'cutoff' => '2020-01-01 00:00:00', + 'blog_id' => 0, + 'last_entry' => 0, + ), + Admin::AUTO_PURGE_GROUP + ); + \ActionScheduler::store()->log_execution( $action_id ); + + $this->assertTrue( + $this->purge->is_running_auto_purge(), + 'In-progress (RUNNING) actions must count as running to prevent overlap' + ); + + as_unschedule_all_actions( Admin::AUTO_PURGE_BATCH_ACTION ); + } + + public function test_is_running_async_deletion_reflects_scheduled_state() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( Admin::ASYNC_DELETION_ACTION ); + } + + $this->assertFalse( + $this->purge->is_running_async_deletion(), + 'No scheduled action means not running' + ); + + as_enqueue_async_action( + Admin::ASYNC_DELETION_ACTION, + array( + 'total' => 1, + 'done' => 0, + 'last_entry' => 1, + 'blog_id' => (int) get_current_blog_id(), + ) + ); + $this->assertTrue( + $this->purge->is_running_async_deletion(), + 'A pending async-deletion action means running' + ); + + as_unschedule_all_actions( Admin::ASYNC_DELETION_ACTION ); + $this->assertFalse( + $this->purge->is_running_async_deletion(), + 'After unscheduling: not running' + ); + } +} diff --git a/tests/phpunit/Admin_Screen_Records_Test.php b/tests/phpunit/Admin_Screen_Records_Test.php new file mode 100644 index 000000000..745c79a0d --- /dev/null +++ b/tests/phpunit/Admin_Screen_Records_Test.php @@ -0,0 +1,51 @@ +admin = $this->plugin->admin; + $this->assertNotEmpty( $this->admin ); + $this->records = $this->admin->records; + } + + public function test_render_list_table() { + $this->records->register_list_table(); + + ob_start(); + $this->records->render_list_table(); + $html = ob_get_clean(); + + $this->assertStringContainsString( '
', $html ); + $this->assertStringContainsString( 'record-filter-form', $html ); + } + + public function test_register_list_table() { + $this->records->register_list_table(); + + $this->assertNotEmpty( $this->admin->list_table ); + $this->assertInstanceOf( '\WP_Stream\List_Table', $this->admin->list_table ); + } +} diff --git a/tests/phpunit/Admin_Screen_Settings_Test.php b/tests/phpunit/Admin_Screen_Settings_Test.php new file mode 100644 index 000000000..e9a56fafd --- /dev/null +++ b/tests/phpunit/Admin_Screen_Settings_Test.php @@ -0,0 +1,45 @@ +admin = $this->plugin->admin; + $this->assertNotEmpty( $this->admin ); + $this->settings = $this->admin->settings; + } + + public function test_render_settings_page() { + ob_start(); + $this->settings->render_settings_page(); + $html = ob_get_clean(); + + $this->assertStringContainsString( '
', $html ); + + global $wp_scripts; + + $this->assertArrayHasKey( 'wp-stream-settings', $wp_scripts->registered ); + } +} diff --git a/tests/phpunit/Admin_Test.php b/tests/phpunit/Admin_Test.php index cf17db321..83738dc8c 100644 --- a/tests/phpunit/Admin_Test.php +++ b/tests/phpunit/Admin_Test.php @@ -55,1392 +55,192 @@ public function test_construct() { $this->assertTrue( function_exists( 'is_plugin_active_for_network' ) ); + $site_access_disabled = false; if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) && ! is_network_admin() ) { - $this->assertTrue( $this->admin->disable_access ); - } else { - $this->assertFalse( $this->admin->disable_access ); - } - } - - public function test_init() { - $this->admin->init(); - $this->assertNotEmpty( $this->admin->network ); - $this->assertNotEmpty( $this->admin->live_update ); - $this->assertNotEmpty( $this->admin->export ); - - $this->assertInstanceOf( '\WP_Stream\Network', $this->admin->network ); - $this->assertInstanceOf( '\WP_Stream\Live_Update', $this->admin->live_update ); - $this->assertInstanceOf( '\WP_Stream\Export', $this->admin->export ); - } - - /** - * The user_has_cap filter is registered in the Admin constructor, but the - * Settings object is only built on init priority 9. A capability check for - * the view cap fired before then (e.g. a firewall plugin on plugins_loaded) - * must be denied, not fatal on the null options chain. - */ - public function test_filter_user_caps_before_settings_initialized() { - $settings = $this->plugin->settings; - $this->plugin->settings = null; - - $user = get_user_by( 'id', $this->admin_user_id ); - $allcaps = $this->admin->filter_user_caps( - array(), - array( $this->admin->view_cap ), - array( $this->admin->view_cap, $this->admin_user_id ), - $user - ); - - $this->plugin->settings = $settings; - - $this->assertArrayNotHasKey( $this->admin->view_cap, $allcaps ); - } - - /** - * Once Settings exists, the view cap is granted to allowed roles as before. - */ - public function test_filter_user_caps_grants_view_cap_to_allowed_role() { - $user = get_user_by( 'id', $this->admin_user_id ); - $allcaps = $this->admin->filter_user_caps( - array(), - array( $this->admin->view_cap ), - array( $this->admin->view_cap, $this->admin_user_id ), - $user - ); - - $this->assertArrayHasKey( $this->admin->view_cap, $allcaps ); - $this->assertTrue( $allcaps[ $this->admin->view_cap ] ); - } - - public function test_prepare_admin_notices() { - // Test no notices - $this->admin->notices = array(); - $this->admin->prepare_admin_notices(); - $this->assertEmpty( $this->admin->notices ); - - // Test settings reset notice - $_GET['message'] = 'settings_reset'; - $this->admin->prepare_admin_notices(); - $this->assertNotEmpty( $this->admin->notices ); - - // Prevent output - $this->admin->notices = array(); - } - - public function test_notice() { - // Start with nothing - $this->admin->notices = array(); - $this->assertEmpty( $this->admin->notices ); - - $message = 'Affirmative, Dave. I read you.'; - $is_error = false; - - $this->admin->notice( $message, $is_error ); - $this->assertNotEmpty( $this->admin->notices ); - ob_start(); - $this->admin->admin_notices(); - $notice = ob_get_clean(); - - $this->assertStringContainsString( $message, $notice ); - $this->assertStringContainsString( 'updated', $notice ); - $this->assertStringNotContainsString( 'error', $notice ); - - // Clear notices and start again - $this->admin->notices = array(); - $this->assertEmpty( $this->admin->notices ); - - $is_error = true; - - $this->admin->notice( $message, $is_error ); - $this->assertNotEmpty( $this->admin->notices ); - ob_start(); - $this->admin->admin_notices(); - $notice = ob_get_clean(); - - $this->assertStringContainsString( $message, $notice ); - $this->assertStringContainsString( 'error', $notice ); - $this->assertStringNotContainsString( 'updated', $notice ); - - // Prevent output - $this->admin->notices = array(); - } - - public function test_admin_notices() { - $allowed_html = ''; - $disallowed_html = ''; - $this->admin->notices = array( - array( - 'message' => "I'm sorry, Dave. I'm afraid I can't do that. $disallowed_html", - 'is_error' => false, - ), - array( - 'message' => "This mission is too important for me to allow you to jeopardize it. $allowed_html", - 'is_error' => false, - ), - ); - - ob_start(); - $this->admin->admin_notices(); - $notices = ob_get_clean(); - - $this->assertStringContainsString( $allowed_html, $notices ); - $this->assertStringNotContainsString( $disallowed_html, $notices ); - $this->assertStringContainsString( str_replace( $disallowed_html, '', $this->admin->notices[0]['message'] ), $notices ); - $this->assertStringContainsString( wpautop( $this->admin->notices[1]['message'] ), $notices ); - - // Prevent output - $this->admin->notices = array(); - } - - public function test_register_menu() { - global $menu; - $menu = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - - do_action( 'admin_menu' ); - - $this->assertNotEmpty( $this->admin->screen_id ); - $this->assertNotEmpty( $this->admin->screen_id['main'] ); - $this->assertNotEmpty( $this->admin->screen_id['settings'] ); - } - - public function test_admin_enqueue_scripts() { - global $wp_scripts; - - // Non-Stream screen - $this->admin->admin_enqueue_scripts( 'edit.php' ); - - $this->assertFalse( wp_script_is( 'wp-stream-admin' ), 'wp-stream-admin script is not enqueued' ); - $this->assertFalse( wp_style_is( 'wp-stream-admin' ), 'wp-stream-admin style is not enqueued' ); - - $this->assertTrue( wp_script_is( 'wp-stream-global' ), 'wp-stream-global script is enqueued' ); - - $this->assertStringContainsString( - 'bulk_actions', - $wp_scripts->get_inline_script_data( 'wp-stream-global', 'before' ), - ); - - // Stream screen - $this->admin->admin_enqueue_scripts( $this->plugin->admin->screen_id['main'] ); - - $this->assertTrue( wp_style_is( 'wp-stream-admin' ), 'wp-stream-admin style is enqueued' ); - - $this->assertTrue( wp_script_is( 'wp-stream-select2' ), 'wp-stream-select2 script is enqueued' ); - $this->assertTrue( wp_script_is( 'wp-stream-select2-en' ), 'wp-stream-select2-en script is enqueued' ); - $this->assertTrue( wp_script_is( 'wp-stream-jquery-timeago' ), 'wp-stream-jquery-timeago script is enqueued' ); - $this->assertTrue( wp_script_is( 'wp-stream-jquery-timeago-en' ), 'wp-stream-jquery-timeago-en script is enqueued' ); - - $this->assertTrue( wp_script_is( 'wp-stream-admin' ), 'wp-stream-admin script is enqueued' ); - $this->assertTrue( wp_script_is( 'wp-stream-live-updates' ), 'wp-stream-live-updates script is enqueued' ); - - $this->assertStringContainsString( - 'i18n', - $wp_scripts->get_inline_script_data( 'wp-stream-admin', 'before' ), - ); - - $this->assertStringContainsString( - 'current_screen', - $wp_scripts->get_inline_script_data( 'wp-stream-live-updates', 'before' ), - ); - $this->assertStringContainsString( - $this->plugin->admin->screen_id['main'], - $wp_scripts->get_inline_script_data( 'wp-stream-live-updates', 'before' ), - ); - } - - public function test_is_stream_screen() { - $this->assertFalse( $this->admin->is_stream_screen() ); - - if ( ! defined( 'WP_ADMIN' ) ) { - define( 'WP_ADMIN', true ); - } - $_GET['page'] = $this->admin->records_page_slug; - - $this->assertTrue( $this->admin->is_stream_screen() ); - } - - public function test_admin_body_class() { - // Make this the Stream screen - if ( ! defined( 'WP_ADMIN' ) ) { - define( 'WP_ADMIN', true ); - } - $_GET['page'] = $this->admin->records_page_slug; - - $classes = 'sit-down-calmy take-a-stress-pill think-things-over'; - $admin_body_classes = $this->admin->admin_body_class( $classes ); - - $this->assertStringContainsString( 'think-things-over ', $admin_body_classes ); - $this->assertStringContainsString( $this->admin->admin_body_class . ' ', $admin_body_classes ); - $this->assertStringContainsString( $this->admin->records_page_slug . ' ', $admin_body_classes ); - } - - public function test_admin_menu_css() { - global $wp_styles; - - $this->admin->admin_menu_css(); - - $dependency = $wp_styles->registered['wp-admin']; - $this->assertArrayHasKey( 'after', $dependency->extra ); - $this->assertNotEmpty( $dependency->extra['after'] ); - $this->assertStringContainsString( "body.{$this->admin->admin_body_class}", $dependency->extra['after'][0] ); - } - - /** - * Also tests private method erase_stream_records - */ - public function test_wp_ajax_reset() { - $_REQUEST['wp_stream_nonce'] = wp_create_nonce( 'stream_nonce' ); - $_REQUEST['wp_stream_nonce_reset'] = wp_create_nonce( 'stream_nonce_reset' ); - - global $wpdb; - - // Create dummy records - $stream_data = $this->dummy_stream_data(); - $wpdb->insert( $wpdb->stream, $stream_data ); - $stream_id = $wpdb->insert_id; - $this->assertNotFalse( $stream_id ); - - // Create dummy meta - $meta_data = $this->dummy_meta_data( $stream_id ); - $wpdb->insert( $wpdb->streammeta, $meta_data ); - $meta_id = $wpdb->insert_id; - $this->assertNotFalse( $meta_id ); - - // Check that records exist - $stream_result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->stream} WHERE ID = %d", $stream_id ) ); - $this->assertNotEmpty( $stream_result ); - - // Check that meta exists - $meta_result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->streammeta} WHERE meta_id = %d", $meta_id ) ); - $this->assertNotEmpty( $meta_result ); - - // Clear records and meta - $reset = $this->admin->wp_ajax_reset(); - $this->assertTrue( $reset ); - - // Check that records have been cleared - $stream_results = $wpdb->get_results( "SELECT * FROM {$wpdb->stream}" ); - $this->assertEmpty( $stream_results ); - - // Check that meta has been cleared - $meta_results = $wpdb->get_results( "SELECT * FROM {$wpdb->streammeta}" ); - $this->assertEmpty( $meta_results ); - } - - /** - * Also tests private method erase_stream_records - */ - public function test_wp_ajax_reset_large_records_blog() { - - if ( ! is_multisite() ) { - $this->markTestSkipped( 'This test requires multisite.' ); - } - - global $wpdb; - - $_REQUEST['wp_stream_nonce'] = wp_create_nonce( 'stream_nonce' ); - $_REQUEST['wp_stream_nonce_reset'] = wp_create_nonce( 'stream_nonce_reset' ); - - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - add_filter( 'wp_stream_is_network_activated', '__return_false' ); - - $stream_data = $this->dummy_stream_data(); - $wpdb->insert( $wpdb->stream, $stream_data ); - $stream_id = $wpdb->insert_id; - $this->assertNotFalse( $stream_id ); - - $meta_data = $this->dummy_meta_data( $stream_id ); - $wpdb->insert( $wpdb->streammeta, $meta_data ); - $meta_id = $wpdb->insert_id; - $this->assertNotFalse( $meta_id ); - - $stream_data_2 = $this->dummy_stream_data_other_blog(); - $wpdb->insert( $wpdb->stream, $stream_data_2 ); - $stream_id_2 = $wpdb->insert_id; - $this->assertNotFalse( $stream_id_2 ); - - $meta_data = $this->dummy_meta_data( $stream_id_2 ); - $wpdb->insert( $wpdb->streammeta, $meta_data ); - $meta_id_2 = $wpdb->insert_id; - $this->assertNotFalse( $meta_id_2 ); - - // Clear records and meta - $reset = $this->admin->wp_ajax_reset(); - $this->assertTrue( $reset ); - - $current_blog = (int) get_current_blog_id(); - - // Assert the scheduled action has been set. - $this->assertTrue( - as_has_scheduled_action( - Admin::ASYNC_DELETION_ACTION - ) - ); - - // Check that records have not been cleared yet. - $stream_results = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$wpdb->stream} WHERE blog_id=%d", - $current_blog - ) - ); - $this->assertNotEmpty( $stream_results ); - - $this->admin->erase_large_records( 1, 0, $meta_id, $current_blog ); - - // Check that records have been cleared. - $stream_results = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$wpdb->stream} WHERE blog_id=%d", - $current_blog - ) - ); - $this->assertEmpty( $stream_results ); - - // Check that records of the other blog have not been cleared. - $stream_results = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$wpdb->stream} WHERE blog_id=%d", - $current_blog + 1 - ) - ); - $this->assertNotEmpty( $stream_results ); - - // Check that one meta has been cleared - $meta_results = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->streammeta}" ); - $this->assertEquals( 1, $meta_results ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - remove_filter( 'wp_stream_is_network_activated', '__return_false' ); - } - - public function test_purge_schedule_setup_uses_action_scheduler_and_unschedules_wp_cron() { - // Simulate a pre-existing legacy WP-Cron event from older Stream versions. - wp_clear_scheduled_hook( 'wp_stream_auto_purge' ); - wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' ); - $this->assertNotFalse( wp_next_scheduled( 'wp_stream_auto_purge' ) ); - - // Make sure AS has no purge actions queued. - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); - } - - $this->admin->purge_schedule_setup(); - - // Legacy WP-Cron event is gone. - $this->assertFalse( - wp_next_scheduled( 'wp_stream_auto_purge' ), - 'Legacy wp_stream_auto_purge WP-Cron event should be cleared' - ); - - // Recurring AS action is scheduled. - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_ACTION ), - 'Recurring AS auto-purge action should be scheduled' - ); - - // Idempotent: calling it again must not schedule a second recurring action. - $this->admin->purge_schedule_setup(); - $ids = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ), - 'ids' - ); - $this->assertCount( 1, $ids, 'purge_schedule_setup() must be idempotent' ); - } - - public function test_purge_scheduled_action_fires_bc_action_once_when_work_runs() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - $hits = 0; - $listener = function () use ( &$hits ) { - ++$hits; - }; - add_action( 'wp_stream_auto_purge', $listener ); - - // Make sure something is eligible so we exercise the full code path. - $this->seed_aged_records( 1, 5 ); - $this->set_records_ttl( 1 ); - - $this->admin->purge_scheduled_action(); - - remove_action( 'wp_stream_auto_purge', $listener ); - $this->assertSame( 1, $hits, 'wp_stream_auto_purge action must fire exactly once per recurring tick when work runs' ); - } - - public function test_purge_scheduled_action_does_not_fire_bc_action_when_cycle_bails() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - // keep_records_indefinitely=1 is one of the bail-out conditions. - if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { - update_site_option( 'wp_stream_network', array( 'general_keep_records_indefinitely' => 1 ) ); - } else { - update_option( 'wp_stream', array( 'general_keep_records_indefinitely' => 1 ) ); - } - - $hits = 0; - $listener = function () use ( &$hits ) { - ++$hits; - }; - add_action( 'wp_stream_auto_purge', $listener ); - - $this->admin->purge_scheduled_action(); - - remove_action( 'wp_stream_auto_purge', $listener ); - $this->assertSame( - 0, - $hits, - 'wp_stream_auto_purge BC action must not fire when the cycle bails out (keep_records_indefinitely)' - ); - } - - public function test_purge_scheduled_action_small_table_fast_path() { - // Default: table is "small" (filter returns false for record_count <= 1M). - global $wpdb; - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - $ids = $this->seed_aged_records( 2, 5 ); - $this->set_records_ttl( 1 ); - - $this->admin->purge_scheduled_action(); - - // Inline DELETE must have run — rows are gone. - $remaining = (int) $wpdb->get_var( - $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->stream} WHERE ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', - ...$ids - ) - ); - $this->assertSame( 0, $remaining, 'Small-table fast path must delete eligible rows inline' ); - - // No batched chain was enqueued. - $this->assertFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'Small-table fast path must not enqueue a batched chain' - ); - - // Reaper still runs so the heal step is observable in Scheduled Actions. - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), - 'Small-table fast path must still enqueue the orphan reaper' - ); - } - - public function test_purge_scheduled_action_large_table_uses_batched_chain() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - // Force the "large table" branch without seeding 1M rows. - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - - $this->seed_aged_records( 2, 5 ); - $this->set_records_ttl( 1 ); - - $this->admin->purge_scheduled_action(); - - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'Large table must enqueue the batched chain' - ); - $this->assertFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), - 'Reaper is enqueued by the terminal batch worker, not by the recurring callback' - ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - } - - public function test_purge_scheduled_action_enqueues_first_batch_with_snapshotted_cutoff() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - } - - // Force the batched path so we can assert batch args. - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - - $this->seed_aged_records( 1, 5 ); - $this->set_records_ttl( 1 ); - - $this->admin->purge_scheduled_action(); - - $scheduled = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ) - ); - $this->assertNotEmpty( $scheduled, 'A first batch must be enqueued when records are eligible' ); - - $action = array_shift( $scheduled ); - $args = $action->get_args(); - $this->assertArrayHasKey( 'cutoff', $args ); - $this->assertArrayHasKey( 'blog_id', $args ); - $this->assertMatchesRegularExpression( - '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', - $args['cutoff'], - 'Cutoff must be a MySQL DATETIME string' - ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - } - - public function test_purge_scheduled_action_respects_keep_indefinitely() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - } - $this->seed_aged_records( 1, 5 ); - - if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { - update_site_option( 'wp_stream_network', array( 'general_keep_records_indefinitely' => 1 ) ); - } else { - update_option( 'wp_stream', array( 'general_keep_records_indefinitely' => 1 ) ); - } - - $this->admin->purge_scheduled_action(); - - $this->assertFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'No batch must be enqueued when keep-records-indefinitely is on' - ); - } - - public function test_purge_scheduled_action_applies_defaults_when_option_missing() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - } - // Drop the option entirely. - if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { - delete_site_option( 'wp_stream_network' ); - } else { - delete_option( 'wp_stream' ); - } - - // Force the batched path so the assertion targets a batch enqueue. - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - - // Seed records older than the default 30-day TTL. - $this->seed_aged_records( 1, 31 ); - - $this->admin->purge_scheduled_action(); - - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'Defaults (30-day TTL) must apply when the settings option is missing' - ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - } - - public function test_purge_scheduled_action_overlap_guard_skips_when_batch_already_pending() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - } - // Overlap guard only applies to the batched chain path. - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - - $this->seed_aged_records( 1, 5 ); - $this->set_records_ttl( 1 ); - - // First call enqueues a batch. - $this->admin->purge_scheduled_action(); - $first = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ), - 'ids' - ); - $this->assertCount( 1, $first ); - - // Second call must be a no-op. - $this->admin->purge_scheduled_action(); - $second = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ), - 'ids' - ); - $this->assertCount( 1, $second, 'Overlap guard must prevent stacking a second batch chain' ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - } - - public function test_purge_scheduled_action_overlap_guard_skips_when_reaper_pending() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - - // Simulate the post-chain state: only the reaper is left pending. - as_enqueue_async_action( - \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, - array(), - \WP_Stream\Admin::AUTO_PURGE_GROUP - ); - - $this->seed_aged_records( 1, 5 ); - $this->set_records_ttl( 1 ); - - $this->admin->purge_scheduled_action(); - - $this->assertFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'Overlap guard must skip when only the reaper is pending' - ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - } - - public function test_purge_scheduled_action_bails_when_ttl_is_zero_or_negative() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - } - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - - $this->seed_aged_records( 1, 5 ); - - // TTL=0 (operator error via CLI/SQL). Must not delete anything. - if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { - update_site_option( 'wp_stream_network', array( 'general_records_ttl' => '0' ) ); - } else { - update_option( 'wp_stream', array( 'general_records_ttl' => '0' ) ); - } - - $this->admin->purge_scheduled_action(); - - $this->assertFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'Non-positive TTL must short-circuit the recurring callback' - ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - } - - public function test_settings_ttl_shortened_triggers_immediate_purge() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - $this->seed_aged_records( 1, 5 ); - - // Simulate the option-changed event: TTL shortened from 30 to 7. - $this->plugin->settings->updated_option_ttl_remove_records( - array( 'general_records_ttl' => 30 ), - array( 'general_records_ttl' => 7 ) - ); - - // The TTL-shortened path enqueues the recurring AS action as a - // one-shot async action so work serializes through AS rather than - // running inline (which would bypass the overlap guard). - $async = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ), - 'ids' - ); - $this->assertNotEmpty( - $async, - 'Shortening TTL must enqueue an immediate auto-purge action via Action Scheduler' - ); - } - - /** - * Exercises the full hook wiring for the TTL-shortened path: writes the - * option via update_option() / update_site_option() and asserts the AS - * enqueue happened. The unit test above invokes the handler directly, - * which would still pass if the underlying hook registration regressed - * (e.g. someone removed Network::updated_option_ttl_remove_records() - * that bridges update_site_option_wp_stream_network → Settings). - * - * Branches by CI lane: single-site lane fires update_option('wp_stream'); - * multisite (network-activated) lane fires update_site_option('wp_stream_network'). - * Both must end up enqueuing AUTO_PURGE_ACTION. - */ - public function test_settings_ttl_shortened_via_option_update_enqueues_purge() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - // Seed a baseline value of 30 days, then shorten to 7. Both writes - // go through the real WP hook chain (update_option_* or - // update_site_option_*), which is the wiring under test. - $this->set_records_ttl( 30 ); - - // Clear anything the baseline write may have enqueued so the - // assertion below targets the shortening event only. - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); - } - - $this->set_records_ttl( 7 ); - - $async = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ), - 'ids' - ); - $this->assertNotEmpty( - $async, - 'Updating the TTL via the option API must enqueue AUTO_PURGE_ACTION through the registered hooks' - ); - } - - public function test_plugin_action_links() { - $links = array( 'Disconnect' ); - $file = plugin_basename( $this->plugin->locations['dir'] . 'stream.php' ); - - $action_links = $this->admin->plugin_action_links( $links, $file ); - - $this->assertStringContainsString( 'Disconnect', $action_links[0] ); - $this->assertStringContainsString( 'Settings', $action_links[1] ); - } - - public function test_render_list_table() { - $this->admin->register_list_table(); - - ob_start(); - $this->admin->render_list_table(); - $html = ob_get_clean(); - - $this->assertStringContainsString( '
', $html ); - $this->assertStringContainsString( 'record-filter-form', $html ); - } - - public function test_render_settings_page() { - ob_start(); - $this->admin->render_settings_page(); - $html = ob_get_clean(); - - $this->assertStringContainsString( '
', $html ); - - global $wp_scripts; - - $this->assertArrayHasKey( 'wp-stream-settings', $wp_scripts->registered ); - } - - public function test_register_list_table() { - $this->admin->register_list_table(); - - $this->assertNotEmpty( $this->admin->list_table ); - $this->assertInstanceOf( '\WP_Stream\List_Table', $this->admin->list_table ); - } - - /** - * Also tests private method role_can_view - */ - public function test_filter_user_caps() { - $user = new \WP_User( $this->admin_user_id ); - - $this->plugin->settings->options['general_role_access'] = array( 'administrator' ); - $this->assertTrue( $user->has_cap( $this->admin->view_cap ) ); - - $this->plugin->settings->options['general_role_access'] = array( 'editor' ); - $this->assertFalse( $user->has_cap( $this->admin->view_cap ) ); - } - - /** - * Also tests private method role_can_view - */ - public function test_filter_role_caps() { - $role = get_role( 'administrator' ); - - $this->plugin->settings->options['general_role_access'] = array( 'administrator' ); - $this->assertTrue( $role->has_cap( $this->admin->view_cap ) ); - - $this->plugin->settings->options['general_role_access'] = array( 'editor' ); - $this->assertFalse( $role->has_cap( $this->admin->view_cap ) ); - } - - /** - * Test Ajax Filters - * - * @group ajax - * @requires PHPUnit 5.7 - */ - public function test_ajax_filters() { - $user = new \WP_User( $this->admin_user_id ); - - $this->_setRole( 'subscriber' ); - - $_POST['filter'] = 'user_id'; - $_POST['q'] = $user->display_name; - $_POST['nonce'] = wp_create_nonce( 'stream_filters_user_search_nonce' ); - - $this->expectException( 'WPAjaxDieStopException' ); - - try { - $this->_handleAjax( 'wp_stream_filters' ); - } catch ( WPAjaxDieStopException $e ) { - // Do nothing. - } - - // Check that the exception was thrown. - $this->assertTrue( isset( $e ) ); - - // The output should be a -1 for failure. - $this->assertEquals( '-1', $e->getMessage() ); - unset( $e ); - - $this->_setRole( 'administrator' ); - - $this->_handleAjax( 'wp_stream_filters' ); - $json = $this->_last_response; - - $this->assertNotEmpty( $json ); - $data = json_decode( $json ); - $this->assertNotFalse( $data ); - $this->assertNotEmpty( $data ); - $this->assertIsArray( $data ); - } - - public function test_get_users_record_meta() { - $user_id = $this->admin_user_id; - $authors = array( - $user_id => get_user_by( 'id', $user_id ), - ); - - $records = $this->admin->get_users_record_meta( $authors ); - - $this->assertArrayHasKey( $user_id, $records ); - $this->assertArrayHasKey( 'text', $records[ $user_id ] ); - $this->assertEquals( 'test_admin', $records[ $user_id ]['text'] ); - } - - public function test_get_user_meta() { - $key = 'message_1'; - $value = 'It is dangerous to remain here. You must leave within two days.'; - update_user_meta( $this->admin_user_id, $key, $value ); - $this->assertEquals( $this->admin->get_user_meta( $this->admin_user_id, $key, true ), $value ); - } - - public function test_update_user_meta() { - $key = 'message_2'; - $value = 'I understand. It is important that you believe me. Look behind you.'; - $this->admin->update_user_meta( $this->admin_user_id, $key, $value ); - $this->assertEquals( get_user_meta( $this->admin_user_id, $key, true ), $value ); - } - - public function test_delete_user_meta() { - $key = 'message_3'; - $value = 'I was David Bowman.'; - - update_user_meta( $this->admin_user_id, $key, $value ); - $this->assertEquals( get_user_meta( $this->admin_user_id, $key, true ), $value ); - - $this->admin->delete_user_meta( $this->admin_user_id, $key ); - - $this->assertEmpty( get_user_meta( $this->admin_user_id, $key, true ) ); - } - - private function dummy_stream_data() { - return array( - 'object_id' => null, - 'site_id' => '1', - 'blog_id' => get_current_blog_id(), - 'user_id' => '1', - 'user_role' => 'administrator', - 'created' => gmdate( 'Y-m-d H:i:s' ), - 'summary' => '"Hello Dave" plugin activated', - 'ip' => '192.168.0.1', - 'connector' => 'installer', - 'context' => 'plugins', - 'action' => 'activated', - ); - } - - private function dummy_stream_data_other_blog() { - return array( - 'object_id' => null, - 'site_id' => '1', - 'blog_id' => (int) get_current_blog_id() + 1, - 'user_id' => '1', - 'user_role' => 'administrator', - 'created' => gmdate( 'Y-m-d H:i:s' ), - 'summary' => '"Hello Dave" plugin activated', - 'ip' => '192.168.0.1', - 'connector' => 'installer', - 'context' => 'plugins', - 'action' => 'activated', - ); - } - - private function dummy_meta_data( $stream_id ) { - return array( - 'record_id' => $stream_id, - 'meta_key' => 'space_helmet', - 'meta_value' => 'false', - ); - } - - /** - * Insert N stream rows aged $days_old days, optionally pinned to a blog id. - * - * @param int $count Number of rows to insert. - * @param int $days_old How many days ago `created` should be set to. - * @param int|null $blog_id Optional blog id override. - * @return int[] Inserted stream IDs. - */ - private function seed_aged_records( int $count, int $days_old, $blog_id = null ): array { - global $wpdb; - $ids = array(); - for ( $i = 0; $i < $count; $i++ ) { - $row = $this->dummy_stream_data(); - $row['created'] = gmdate( 'Y-m-d H:i:s', strtotime( $days_old . ' days ago' ) ); - if ( null !== $blog_id ) { - $row['blog_id'] = $blog_id; - } - $wpdb->insert( $wpdb->stream, $row ); - $stream_id = (int) $wpdb->insert_id; - $ids[] = $stream_id; - $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $stream_id ) ); - } - return $ids; - } - - /** - * Set the records TTL in whichever option applies on this install. - * - * @param int $days Number of days to retain records for. - */ - private function set_records_ttl( int $days ) { - if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { - $options = (array) get_site_option( 'wp_stream_network', array() ); - $options['general_records_ttl'] = (string) $days; - unset( $options['general_keep_records_indefinitely'] ); - update_site_option( 'wp_stream_network', $options ); - } else { - $options = (array) get_option( 'wp_stream', array() ); - $options['general_records_ttl'] = (string) $days; - unset( $options['general_keep_records_indefinitely'] ); - update_option( 'wp_stream', $options ); - } - } - - public function test_ajax_clean_orphan_meta_schedules_reaper() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); - wp_set_current_user( $user_id ); - - $_REQUEST['wp_stream_nonce_clean_orphan_meta'] = wp_create_nonce( 'stream_nonce_clean_orphan_meta' ); - - $result = $this->admin->wp_ajax_clean_orphan_meta(); - $this->assertTrue( $result ); - - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), - 'Ajax handler must enqueue the reaper action' - ); - - unset( $_REQUEST['wp_stream_nonce_clean_orphan_meta'] ); - } - - /** - * Security boundary: a user without WP_STREAM_SETTINGS_CAPABILITY must - * be rejected before the handler reaches the AS enqueue. Mirrors the - * capability check used by the reset/erase handlers in this class. - * - * Uses _handleAjax() so WP_Ajax_UnitTestCase's output-buffer machinery - * runs (the handler calls wp_die(), which the testcase die handler - * routes through ob_get_clean()); calling the method directly would - * leave the buffer state ambiguous and PHPUnit would mark the test risky. - * - * @throws \WPAjaxDieStopException Thrown by the testcase die handler when - * the rejected request triggers wp_die(). - */ - public function test_ajax_clean_orphan_meta_denies_users_without_settings_cap() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + $options = (array) get_site_option( 'wp_stream_network', array() ); + $site_access = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1; + $site_access_disabled = ! $site_access; } - $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); - wp_set_current_user( $subscriber_id ); - - $_REQUEST['wp_stream_nonce_clean_orphan_meta'] = wp_create_nonce( 'stream_nonce_clean_orphan_meta' ); - - $this->expectException( \WPAjaxDieStopException::class ); - - try { - $this->_handleAjax( 'wp_stream_clean_orphan_meta' ); - } catch ( \WPAjaxDieStopException $e ) { - unset( $_REQUEST['wp_stream_nonce_clean_orphan_meta'] ); - $this->assertFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), - 'No work must be enqueued for a rejected request' - ); - throw $e; + $has_admin_menu = has_action( 'admin_menu', array( $this->admin->menu, 'register_menu' ) ); + if ( $site_access_disabled ) { + $this->assertFalse( $has_admin_menu ); + } else { + $this->assertNotFalse( $has_admin_menu ); } } - public function test_auto_purge_reaper_deletes_orphaned_meta_only() { - global $wpdb; - - // Seed a real record with meta, then a free-floating meta row pointing at - // a non-existent record_id. - $stream_data = $this->dummy_stream_data(); - $stream_data['created'] = gmdate( 'Y-m-d H:i:s', strtotime( '5 days ago' ) ); - $wpdb->insert( $wpdb->stream, $stream_data ); - $real_id = (int) $wpdb->insert_id; - $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $real_id ) ); - - // Orphan meta: record_id points nowhere. - $orphan_record_id = $real_id + 999999; - $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $orphan_record_id ) ); - - $before_orphans = (int) $wpdb->get_var( - $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $orphan_record_id ) - ); - $this->assertSame( 1, $before_orphans ); - - $this->admin->auto_purge_reaper(); - - $after_orphans = (int) $wpdb->get_var( - $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $orphan_record_id ) - ); - $linked_meta = (int) $wpdb->get_var( - $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $real_id ) - ); + public function test_init() { + $this->admin->init(); + $this->assertNotEmpty( $this->admin->network ); + $this->assertNotEmpty( $this->admin->live_update ); + $this->assertNotEmpty( $this->admin->export ); - $this->assertSame( 0, $after_orphans, 'Reaper must delete meta rows whose parent stream row is absent' ); - $this->assertSame( 1, $linked_meta, 'Reaper must not touch meta rows whose parent still exists' ); + $this->assertInstanceOf( '\WP_Stream\Network', $this->admin->network ); + $this->assertInstanceOf( '\WP_Stream\Live_Update', $this->admin->live_update ); + $this->assertInstanceOf( '\WP_Stream\Export', $this->admin->export ); } - public function test_auto_purge_batch_deletes_window_and_chains_next_batch() { - global $wpdb; + /** + * The user_has_cap filter is registered in the Admin constructor, but the + * Settings object is only built on init priority 9. A capability check for + * the view cap fired before then (e.g. a firewall plugin on plugins_loaded) + * must be denied, not fatal on the null options chain. + */ + public function test_filter_user_caps_before_settings_initialized() { + $settings = $this->plugin->settings; + $this->plugin->settings = null; - // Force a small batch size so we can chain twice without seeding huge data. - add_filter( - 'wp_stream_batch_size', - function () { - return 2; - } + $user = get_user_by( 'id', $this->admin_user_id ); + $allcaps = $this->admin->filter_user_caps( + array(), + array( $this->admin->view_cap ), + array( $this->admin->view_cap, $this->admin_user_id ), + $user ); - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - // Seed 5 aged rows. With batch_size=2 the chain runs 3 batches + reaper. - $this->seed_aged_records( 5, 5 ); - - $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) - ->sub( \DateInterval::createFromDateString( '1 days' ) ) - ->format( 'Y-m-d H:i:s' ); - - $before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); - - $this->admin->auto_purge_batch( $cutoff, 0 ); + $this->plugin->settings = $settings; - $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); - $this->assertLessThan( $before, $remaining, 'Batch must delete at least one row' ); - $this->assertGreaterThan( 0, $remaining, 'Batch must not delete more than one window of rows' ); + $this->assertArrayNotHasKey( $this->admin->view_cap, $allcaps ); + } - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'Next batch must be chained when more eligible rows remain' + /** + * Once Settings exists, the view cap is granted to allowed roles as before. + */ + public function test_filter_user_caps_grants_view_cap_to_allowed_role() { + $user = get_user_by( 'id', $this->admin_user_id ); + $allcaps = $this->admin->filter_user_caps( + array(), + array( $this->admin->view_cap ), + array( $this->admin->view_cap, $this->admin_user_id ), + $user ); - remove_all_filters( 'wp_stream_batch_size' ); - } - - public function test_auto_purge_batch_throws_on_empty_cutoff() { - $this->expectException( \InvalidArgumentException::class ); - $this->admin->auto_purge_batch( '', 0, 0 ); + $this->assertArrayHasKey( $this->admin->view_cap, $allcaps ); + $this->assertTrue( $allcaps[ $this->admin->view_cap ] ); } - public function test_auto_purge_batch_enqueues_reaper_when_no_rows_remain() { - global $wpdb; - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - // Wipe any leftover rows from earlier tests so nothing is eligible. - $wpdb->query( "DELETE FROM {$wpdb->stream}" ); - $wpdb->query( "DELETE FROM {$wpdb->streammeta}" ); - - $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) - ->sub( \DateInterval::createFromDateString( '1 days' ) ) - ->format( 'Y-m-d H:i:s' ); + public function test_prepare_admin_notices() { + // Test no notices + $this->admin->notices = array(); + $this->admin->prepare_admin_notices(); + $this->assertEmpty( $this->admin->notices ); - $this->admin->auto_purge_batch( $cutoff, 0 ); + // Test settings reset notice + $_GET['message'] = 'settings_reset'; + $this->admin->prepare_admin_notices(); + $this->assertNotEmpty( $this->admin->notices ); - $this->assertFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'No further batch must be chained when nothing is eligible' - ); - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), - 'Reaper must be enqueued as the terminal step of the chain' - ); + // Prevent output + $this->admin->notices = array(); } - public function test_auto_purge_batch_chain_strides_down_by_window() { - global $wpdb; + public function test_notice() { + // Start with nothing + $this->admin->notices = array(); + $this->assertEmpty( $this->admin->notices ); - // Force a small batch size so we can chain multiple times. - add_filter( - 'wp_stream_batch_size', - function () { - return 3; - } - ); + $message = 'Affirmative, Dave. I read you.'; + $is_error = false; - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } + $this->admin->notice( $message, $is_error ); + $this->assertNotEmpty( $this->admin->notices ); + ob_start(); + $this->admin->admin_notices(); + $notice = ob_get_clean(); - $ids = $this->seed_aged_records( 4, 5 ); - sort( $ids ); - $top_id = end( $ids ); + $this->assertStringContainsString( $message, $notice ); + $this->assertStringContainsString( 'updated', $notice ); + $this->assertStringNotContainsString( 'error', $notice ); - $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) - ->sub( \DateInterval::createFromDateString( '1 days' ) ) - ->format( 'Y-m-d H:i:s' ); + // Clear notices and start again + $this->admin->notices = array(); + $this->assertEmpty( $this->admin->notices ); - // First batch (last_entry=0) should pick the highest ID and pass - // last_entry = top_id - batch_size to the next batch. - $this->admin->auto_purge_batch( $cutoff, 0, 0 ); + $is_error = true; - $pending = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ) - ); - $this->assertNotEmpty( $pending ); - $next_args = array_shift( $pending )->get_args(); + $this->admin->notice( $message, $is_error ); + $this->assertNotEmpty( $this->admin->notices ); + ob_start(); + $this->admin->admin_notices(); + $notice = ob_get_clean(); - $this->assertArrayHasKey( 'last_entry', $next_args ); - $this->assertSame( - max( 0, $top_id - 3 ), - (int) $next_args['last_entry'], - 'Next batch must receive last_entry = top_id - batch_size' - ); + $this->assertStringContainsString( $message, $notice ); + $this->assertStringContainsString( 'error', $notice ); + $this->assertStringNotContainsString( 'updated', $notice ); - remove_all_filters( 'wp_stream_batch_size' ); + // Prevent output + $this->admin->notices = array(); } - /** - * Acceptance criterion: "Per-site activations only purge the current blog." - * - * The batch worker scoping is covered above - * ({@see test_auto_purge_batch_scopes_to_blog_id_when_non_zero}); this - * test closes the gap on the routing decision in - * {@see Admin::purge_scheduled_action()}: - * - * $blog_id = $this->plugin->is_multisite_not_network_activated() - * ? (int) get_current_blog_id() - * : 0; - * - * Forces is_multisite_not_network_activated() to return true via a Plugin - * stub (CI's multisite lane runs with network-activated = true), then - * asserts the enqueued batch carries the current blog_id rather than 0. - */ - public function test_purge_scheduled_action_scopes_to_current_blog_when_not_network_activated() { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Per-site scoping is multisite-only' ); - } - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - } - - // Force the batched path so the assertion can read $args['blog_id']. - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); - - $current_blog = (int) get_current_blog_id(); - $this->seed_aged_records( 1, 5, $current_blog ); - $this->set_records_ttl( 1 ); - - // Swap in a Plugin stub that reports per-site activation. - $real_plugin = $this->admin->plugin; - $stub = new class( $real_plugin ) { - public $settings; - public $db; - public $locations; - public $admin; - public $connectors; - public $scheduler; - public function __construct( $real ) { - $this->settings = $real->settings; - $this->db = $real->db; - $this->locations = $real->locations; - $this->admin = $real->admin; - $this->connectors = $real->connectors; - $this->scheduler = $real->scheduler; - } - public function is_multisite_not_network_activated() { - return true; - } - public function is_multisite_network_activated() { - return false; - } - public function is_large_records_table( int $n ): bool { - return apply_filters( 'wp_stream_is_large_records_table', $n > 1000000, $n ); - } - public function __call( $name, $args ) { - return call_user_func_array( array( $this->settings->plugin ?? null, $name ), $args ); - } - }; - $this->admin->plugin = $stub; + public function test_admin_notices() { + $allowed_html = ''; + $disallowed_html = ''; + $this->admin->notices = array( + array( + 'message' => "I'm sorry, Dave. I'm afraid I can't do that. $disallowed_html", + 'is_error' => false, + ), + array( + 'message' => "This mission is too important for me to allow you to jeopardize it. $allowed_html", + 'is_error' => false, + ), + ); - try { - $this->admin->purge_scheduled_action(); + ob_start(); + $this->admin->admin_notices(); + $notices = ob_get_clean(); - $scheduled = as_get_scheduled_actions( - array( - 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - 'status' => \ActionScheduler_Store::STATUS_PENDING, - ) - ); - $this->assertNotEmpty( - $scheduled, - 'Per-site activation must still enqueue a batch when records are eligible' - ); + $this->assertStringContainsString( $allowed_html, $notices ); + $this->assertStringNotContainsString( $disallowed_html, $notices ); + $this->assertStringContainsString( str_replace( $disallowed_html, '', $this->admin->notices[0]['message'] ), $notices ); + $this->assertStringContainsString( wpautop( $this->admin->notices[1]['message'] ), $notices ); - $action = array_shift( $scheduled ); - $args = $action->get_args(); - $this->assertSame( - $current_blog, - (int) $args['blog_id'], - 'Per-site activation must scope the batch to the current blog (not 0 / all blogs)' - ); - } finally { - $this->admin->plugin = $real_plugin; - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); - }//end try + // Prevent output + $this->admin->notices = array(); } + public function test_plugin_action_links() { + $links = array( 'Disconnect' ); + $file = plugin_basename( $this->plugin->locations['dir'] . 'stream.php' ); - public function test_auto_purge_batch_scopes_to_blog_id_when_non_zero() { - global $wpdb; - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Multisite scoping test' ); - } - - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - $current_blog = (int) get_current_blog_id(); - $other_blog = $current_blog + 1000; - // arbitrary distinct id, no real blog required for SQL scoping. - - $this->seed_aged_records( 1, 5, $current_blog ); - $this->seed_aged_records( 1, 5, $other_blog ); - - $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) - ->sub( \DateInterval::createFromDateString( '1 days' ) ) - ->format( 'Y-m-d H:i:s' ); - - $this->admin->auto_purge_batch( $cutoff, $current_blog ); + $action_links = $this->admin->plugin_action_links( $links, $file ); - $remaining_other = (int) $wpdb->get_var( - $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->stream} WHERE blog_id = %d", $other_blog ) - ); - $this->assertSame( 1, $remaining_other, 'Per-blog scoping must leave sibling blogs untouched' ); + $this->assertStringContainsString( 'Disconnect', $action_links[0] ); + $this->assertStringContainsString( 'Settings', $action_links[1] ); } + /** + * Also tests private method role_can_view + */ + public function test_filter_user_caps() { + $user = new \WP_User( $this->admin_user_id ); - public function test_is_running_auto_purge_reflects_chain_state() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - $this->assertFalse( - \WP_Stream\Admin::is_running_auto_purge(), - 'No scheduled actions means not running' - ); - - as_enqueue_async_action( - \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - array( - 'cutoff' => '2020-01-01 00:00:00', - 'blog_id' => 0, - 'last_entry' => 0, - ), - \WP_Stream\Admin::AUTO_PURGE_GROUP - ); - $this->assertTrue( - \WP_Stream\Admin::is_running_auto_purge(), - 'A pending batch action means running' - ); - - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_enqueue_async_action( - \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, - array(), - \WP_Stream\Admin::AUTO_PURGE_GROUP - ); - $this->assertTrue( - \WP_Stream\Admin::is_running_auto_purge(), - 'A pending reaper action means running' - ); + $this->plugin->settings->options['general_role_access'] = array( 'administrator' ); + $this->assertTrue( $user->has_cap( $this->admin->view_cap ) ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - $this->assertFalse( - \WP_Stream\Admin::is_running_auto_purge(), - 'Chain drained: not running' - ); + $this->plugin->settings->options['general_role_access'] = array( 'editor' ); + $this->assertFalse( $user->has_cap( $this->admin->view_cap ) ); } - public function test_is_running_auto_purge_includes_in_progress_actions() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - } - - // Enqueue and then flip the action's status to IN-PROGRESS to simulate - // the runner having dequeued an action and started executing it. - // Without RUNNING-aware filtering, is_running_auto_purge() would - // return false here and the overlap guard would let a second chain - // stack against the same rows. - $action_id = as_enqueue_async_action( - \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - array( - 'cutoff' => '2020-01-01 00:00:00', - 'blog_id' => 0, - 'last_entry' => 0, - ), - \WP_Stream\Admin::AUTO_PURGE_GROUP - ); - \ActionScheduler::store()->log_execution( $action_id ); + /** + * Also tests private method role_can_view + */ + public function test_filter_role_caps() { + $role = get_role( 'administrator' ); - $this->assertTrue( - \WP_Stream\Admin::is_running_auto_purge(), - 'In-progress (RUNNING) actions must count as running to prevent overlap' - ); + $this->plugin->settings->options['general_role_access'] = array( 'administrator' ); + $this->assertTrue( $role->has_cap( $this->admin->view_cap ) ); - as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + $this->plugin->settings->options['general_role_access'] = array( 'editor' ); + $this->assertFalse( $role->has_cap( $this->admin->view_cap ) ); } /** * Integration test for the running-state UI swap. Asserts that the * "Clean Orphaned Meta" field in Settings::get_fields() flips from * type=link to type=none and swaps its description when an auto-purge - * chain is active. is_running_auto_purge() is covered in isolation - * above; this test closes the loop on the consumer that drives the UI. + * chain is active. Admin_Purge::is_running_auto_purge() is covered in + * isolation in Admin_Purge_Test; this test closes the loop on the + * consumer that drives the UI. * * Replaces the e2e specs removed in b4c8f287 for activation-race * fragility — same assertions, no browser/AS-runner timing surface. @@ -1498,37 +298,6 @@ public function test_clean_orphan_meta_field_reflects_running_state() { as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); } - public function test_is_running_async_deletion_reflects_scheduled_state() { - if ( function_exists( 'as_unschedule_all_actions' ) ) { - as_unschedule_all_actions( \WP_Stream\Admin::ASYNC_DELETION_ACTION ); - } - - $this->assertFalse( - \WP_Stream\Admin::is_running_async_deletion(), - 'No scheduled action means not running' - ); - - as_enqueue_async_action( - \WP_Stream\Admin::ASYNC_DELETION_ACTION, - array( - 'total' => 1, - 'done' => 0, - 'last_entry' => 1, - 'blog_id' => (int) get_current_blog_id(), - ) - ); - $this->assertTrue( - \WP_Stream\Admin::is_running_async_deletion(), - 'A pending async-deletion action means running' - ); - - as_unschedule_all_actions( \WP_Stream\Admin::ASYNC_DELETION_ACTION ); - $this->assertFalse( - \WP_Stream\Admin::is_running_async_deletion(), - 'After unscheduling: not running' - ); - } - /** * Integration test for the "Reset Stream Database" running-state UI swap. * Asserts that the delete_all_records field flips from type=link to @@ -1622,25 +391,4 @@ public function test_get_deletion_warning_respects_precomputed_state() { as_unschedule_all_actions( \WP_Stream\Admin::ASYNC_DELETION_ACTION ); } - - public function test_register_hooks_auto_purge_action_scheduler_callbacks() { - // The Admin instance is constructed by the test bootstrap, so register() - // has already run. Just assert the actions are wired up. - $this->assertNotFalse( - has_action( \WP_Stream\Admin::AUTO_PURGE_ACTION, array( $this->admin, 'purge_scheduled_action' ) ), - 'Recurring auto-purge AS callback should be registered' - ); - $this->assertNotFalse( - has_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, array( $this->admin, 'auto_purge_batch' ) ), - 'Auto-purge batch worker should be registered' - ); - $this->assertNotFalse( - has_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, array( $this->admin, 'auto_purge_reaper' ) ), - 'Auto-purge reaper should be registered' - ); - $this->assertFalse( - has_action( 'wp_stream_auto_purge', array( $this->admin, 'purge_scheduled_action' ) ), - 'Legacy wp_stream_auto_purge hook should no longer dispatch to purge_scheduled_action directly' - ); - } } diff --git a/tests/phpunit/Scheduler_Handoff_Test.php b/tests/phpunit/Scheduler_Handoff_Test.php index 0b5d630d1..395b44e99 100644 --- a/tests/phpunit/Scheduler_Handoff_Test.php +++ b/tests/phpunit/Scheduler_Handoff_Test.php @@ -22,6 +22,13 @@ class Scheduler_Handoff_Test extends WP_StreamTestCase { */ protected $admin; + /** + * Purge collaborator under test. + * + * @var Admin_Purge + */ + protected $purge; + /** * Scheduler active before a test swapped it. * @@ -32,6 +39,7 @@ class Scheduler_Handoff_Test extends WP_StreamTestCase { public function setUp(): void { parent::setUp(); $this->admin = $this->plugin->admin; + $this->purge = $this->get_admin_collaborator( $this->admin, 'purge' ); $this->original_scheduler = $this->plugin->scheduler; $this->clear(); } @@ -68,7 +76,7 @@ public function test_cron_active_clears_stray_action_scheduler_recurring() { $this->assertNotFalse( as_next_scheduled_action( Admin::AUTO_PURGE_ACTION ) ); $this->plugin->scheduler = new Cron_Scheduler(); - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); $this->assertFalse( as_next_scheduled_action( Admin::AUTO_PURGE_ACTION ), @@ -90,7 +98,7 @@ public function test_action_scheduler_active_clears_stray_wp_cron_recurring() { $this->assertNotFalse( wp_next_scheduled( Admin::AUTO_PURGE_ACTION ) ); $this->plugin->scheduler = new AS_Scheduler(); - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); $this->assertFalse( wp_next_scheduled( Admin::AUTO_PURGE_ACTION ), @@ -121,7 +129,7 @@ public function test_disable_clears_action_scheduler_store_when_cron_active() { $this->plugin->scheduler = new Cron_Scheduler(); add_filter( 'wp_stream_enable_auto_purge', '__return_false' ); - $this->admin->purge_schedule_setup(); + $this->purge->purge_schedule_setup(); remove_all_filters( 'wp_stream_enable_auto_purge' ); $this->assertFalse( diff --git a/tests/testcase.php b/tests/testcase.php index 21705315f..811bf0713 100644 --- a/tests/testcase.php +++ b/tests/testcase.php @@ -194,6 +194,20 @@ public function expectDeprecated(): void { // phpcs:ignore WordPress.NamingConve add_action( 'doing_it_wrong_trigger_error', '__return_false' ); } + /** + * Resolve an Admin collaborator. + * + * Collaborators are public on Admin; this helper remains for tests that + * prefer a named lookup. + * + * @param Admin $admin Admin instance. + * @param string $name Property name (menu|assets|records|settings|ajax|purge). + * @return object + */ + protected function get_admin_collaborator( Admin $admin, string $name ) { + return $admin->{$name}; + } + /** * Helper function to check validity of action *