diff --git a/includes/reader-activation/integrations/class-contact-pull.php b/includes/reader-activation/integrations/class-contact-pull.php index acc690b942..4d919f6e75 100644 --- a/includes/reader-activation/integrations/class-contact-pull.php +++ b/includes/reader-activation/integrations/class-contact-pull.php @@ -49,6 +49,11 @@ class Contact_Pull { */ const RETRY_HOOK = 'newspack_contact_pull_retry'; + /** + * ActionScheduler hook for retrying a failed bulk integration pull. + */ + const BULK_RETRY_HOOK = 'newspack_bulk_contact_pull_retry'; + /** * Maximum number of retries for a failed integration pull. */ @@ -73,6 +78,7 @@ class Contact_Pull { public static function init_hooks() { add_action( 'wp_ajax_' . self::AJAX_ACTION, [ __CLASS__, 'handle_ajax_pull' ] ); add_action( self::RETRY_HOOK, [ __CLASS__, 'execute_integration_retry' ] ); + add_action( self::BULK_RETRY_HOOK, [ __CLASS__, 'execute_bulk_retry' ] ); add_filter( 'newspack_action_scheduler_hook_labels', [ __CLASS__, 'register_hook_labels' ] ); } @@ -83,7 +89,8 @@ public static function init_hooks() { * @return array */ public static function register_hook_labels( $labels ) { - $labels[ self::RETRY_HOOK ] = __( 'Contact Pull Retry', 'newspack-plugin' ); + $labels[ self::RETRY_HOOK ] = __( 'Contact Pull Retry', 'newspack-plugin' ); + $labels[ self::BULK_RETRY_HOOK ] = __( 'Bulk Contact Pull Retry', 'newspack-plugin' ); return $labels; } @@ -180,6 +187,55 @@ public static function pull_all( $user_id ) { return true; } + /** + * Pull contact data for multiple users from all active integrations in bulk. + * + * Each integration receives all user IDs at once via pull_contacts_data(), + * allowing integrations with native batch read APIs to handle them efficiently. + * Pulled data is filtered by enabled incoming fields and stored via Reader_Data. + * + * @param int[] $user_ids Array of WordPress user IDs. + * + * @return true|\WP_Error True if all succeeded, or WP_Error with combined messages. + */ + public static function bulk_pull_from_integrations( $user_ids ) { + $integrations = Integrations::get_active_integrations(); + $errors = []; + + foreach ( $integrations as $integration_id => $integration ) { + $selected_fields = $integration->get_enabled_incoming_fields(); + if ( empty( $selected_fields ) ) { + continue; + } + + Logger::log( sprintf( 'Bulk pulling %d user(s) from integration "%s".', count( $user_ids ), $integration_id ), self::LOGGER_HEADER ); + + $results = $integration->pull_contacts_data( $user_ids ); + + if ( \is_wp_error( $results ) ) { + Logger::log( sprintf( 'Bulk pull failed for integration "%s": %s', $integration_id, $results->get_error_message() ), self::LOGGER_HEADER ); + self::schedule_bulk_retry( $integration_id, $user_ids, 0, $results ); + $errors[] = sprintf( '[%s] Batch failed: %s', $integration_id, $results->get_error_message() ); + } else { + foreach ( $results as $user_id => $result ) { + if ( \is_wp_error( $result ) ) { + Logger::log( sprintf( 'Pull failed for user %d from integration "%s": %s', $user_id, $integration_id, $result->get_error_message() ), self::LOGGER_HEADER ); + self::schedule_integration_retry( $integration_id, $user_id, 0, $result ); + $errors[] = sprintf( '[%s] User %d: %s', $integration_id, $user_id, $result->get_error_message() ); + } elseif ( is_array( $result ) ) { + self::store_pulled_data( $user_id, $result, $integration ); + } + } + } + } + + if ( ! empty( $errors ) ) { + return new \WP_Error( 'newspack_bulk_pull_failed', implode( '; ', $errors ) ); + } + + return true; + } + /** * Fire a blocking loopback request to pull data for a single integration. * @@ -239,6 +295,31 @@ public static function handle_ajax_pull() { wp_send_json_success(); } + /** + * Filter pulled data by enabled incoming fields and store via Reader_Data. + * + * @param int $user_id WordPress user ID. + * @param array $data Raw pulled data (field_key => value). + * @param \Newspack\Reader_Activation\Integration $integration The integration instance. + */ + private static function store_pulled_data( $user_id, $data, $integration ) { + $selected_fields = $integration->get_enabled_incoming_fields(); + $selected_keys = array_flip( + array_map( + function( $field ) { + return $field->get_key(); + }, + $selected_fields + ) + ); + $data = array_intersect_key( $data, $selected_keys ); + Logger::log( 'Pulled data from ' . $integration->get_id() . ' for user ' . $user_id . ': ' . wp_json_encode( $data ) ); + + foreach ( $data as $key => $value ) { + \Newspack\Reader_Data::update_item( $user_id, $key, wp_json_encode( $value ) ); + } + } + /** * Pull data from a single integration and store selected fields. * @@ -260,20 +341,7 @@ public static function pull_single_integration( $user_id, $integration ) { return $data; } - $selected_keys = array_flip( - array_map( - function( $field ) { - return $field->get_key(); - }, - $selected_fields - ) - ); - $data = array_intersect_key( $data, $selected_keys ); - Logger::log( 'Pulled data from ' . $integration->get_id() . ': ' . wp_json_encode( $data ) ); - - foreach ( $data as $key => $value ) { - \Newspack\Reader_Data::update_item( $user_id, $key, wp_json_encode( $value ) ); - } + self::store_pulled_data( $user_id, $data, $integration ); return true; } catch ( \Throwable $e ) { @@ -376,6 +444,157 @@ private static function schedule_integration_retry( $integration_id, $user_id, $ ); } + /** + * Schedule a retry for a failed bulk integration pull via ActionScheduler. + * + * @param string $integration_id The integration ID. + * @param int[] $user_ids The WordPress user IDs. + * @param int $retry_count Current retry count (0 = first failure). + * @param \WP_Error $error The error from the failure. + */ + private static function schedule_bulk_retry( $integration_id, $user_ids, $retry_count, $error ) { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + return; + } + + $error_message = $error->get_error_message(); + $next_retry = $retry_count + 1; + + if ( $next_retry > self::MAX_RETRIES ) { + Logger::log( + sprintf( + 'Max retries (%d) reached for bulk pull of integration "%s" (%d users). Giving up. Last error: %s', + self::MAX_RETRIES, + $integration_id, + count( $user_ids ), + $error_message + ), + self::LOGGER_HEADER + ); + do_action( + 'newspack_bulk_pull_retry_exhausted', + [ + 'integration_id' => $integration_id, + 'user_ids' => $user_ids, + 'reason' => $error_message, + ] + ); + return; + } + + $backoff_index = min( $retry_count, count( self::RETRY_BACKOFF ) - 1 ); + $backoff_seconds = self::RETRY_BACKOFF[ $backoff_index ]; + + $retry_data = [ + 'integration_id' => $integration_id, + 'user_ids' => $user_ids, + 'retry_count' => $next_retry, + 'reason' => $error_message, + ]; + + \as_schedule_single_action( + time() + $backoff_seconds, + self::BULK_RETRY_HOOK, + [ $retry_data ], + Integrations::get_action_group( $integration_id ) + ); + + Logger::log( + sprintf( + 'Scheduled bulk pull retry %d/%d for integration "%s" (%d users) in %ds. Error: %s', + $next_retry, + self::MAX_RETRIES, + $integration_id, + count( $user_ids ), + $backoff_seconds, + $error_message + ), + self::LOGGER_HEADER + ); + } + + /** + * Execute a bulk integration pull retry from ActionScheduler. + * + * @param array $retry_data The retry data containing integration_id, user_ids, and retry_count. + * + * @throws \Exception When the final retry fails, so ActionScheduler marks the action as "failed". + */ + public static function execute_bulk_retry( $retry_data ) { + if ( ! is_array( $retry_data ) || empty( $retry_data['integration_id'] ) || empty( $retry_data['user_ids'] ) ) { + Logger::log( 'Invalid bulk pull retry data received from Action Scheduler.', self::LOGGER_HEADER, 'error' ); + return; + } + + $integration_id = $retry_data['integration_id']; + $user_ids = $retry_data['user_ids']; + $retry_count = $retry_data['retry_count'] ?? 1; + + $integration = Integrations::get_integration( $integration_id ); + if ( ! $integration || ! Integrations::is_enabled( $integration_id ) ) { + Logger::log( sprintf( 'Integration "%s" not found or not enabled on bulk pull retry %d.', $integration_id, $retry_count ), self::LOGGER_HEADER, 'error' ); + return; + } + + $selected_fields = $integration->get_enabled_incoming_fields(); + if ( empty( $selected_fields ) ) { + Logger::log( sprintf( 'No incoming fields enabled for integration "%s" on bulk pull retry %d.', $integration_id, $retry_count ), self::LOGGER_HEADER ); + return; + } + + // Filter out stale user IDs. + $valid_user_ids = array_filter( + $user_ids, + function ( $user_id ) { + return (bool) \get_userdata( $user_id ); + } + ); + + if ( empty( $valid_user_ids ) ) { + Logger::log( sprintf( 'Bulk pull retry %d for integration "%s": no valid users remaining.', $retry_count, $integration_id ), self::LOGGER_HEADER ); + return; + } + + Logger::log( sprintf( 'Executing bulk pull retry %d/%d for integration "%s" (%d users).', $retry_count, self::MAX_RETRIES, $integration_id, count( $valid_user_ids ) ), self::LOGGER_HEADER ); + + $results = $integration->pull_contacts_data( $valid_user_ids ); + + if ( \is_wp_error( $results ) ) { + Logger::log( sprintf( 'Bulk pull retry %d failed for integration "%s": %s', $retry_count, $integration_id, $results->get_error_message() ), self::LOGGER_HEADER ); + self::schedule_bulk_retry( $integration_id, $valid_user_ids, $retry_count, $results ); + + if ( $retry_count >= self::MAX_RETRIES ) { + throw new \Exception( + esc_html( + sprintf( + 'Bulk pull retry %d/%d failed for integration "%s" (%d users): %s', + $retry_count, + self::MAX_RETRIES, + $integration_id, + count( $valid_user_ids ), + $results->get_error_message() + ) + ) + ); + } + } else { + $failures = 0; + foreach ( $results as $user_id => $result ) { + if ( \is_wp_error( $result ) ) { + self::schedule_integration_retry( $integration_id, $user_id, 0, $result ); + $failures++; + } elseif ( is_array( $result ) ) { + self::store_pulled_data( $user_id, $result, $integration ); + } + } + if ( $failures > 0 ) { + Logger::log( sprintf( 'Bulk pull retry %d for integration "%s": %d/%d users failed, scheduled individual retries.', $retry_count, $integration_id, $failures, count( $valid_user_ids ) ), self::LOGGER_HEADER ); + } else { + Logger::log( sprintf( 'Bulk pull retry %d for integration "%s": all %d users succeeded.', $retry_count, $integration_id, count( $valid_user_ids ) ), self::LOGGER_HEADER ); + } + } + } + /** * Execute an integration pull retry from ActionScheduler. * diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 82d655fd0b..7357a74f2c 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -160,6 +160,30 @@ abstract public function can_sync( $return_errors = false ); */ abstract public function push_contact_data( $contact, $context = '', $existing_contact = null ); + /** + * Push multiple contacts to the integration destination. + * + * Default implementation iterates push_contact_data() for each contact. + * Integrations with native batch APIs should override this method. + * + * @param array $contacts Array of contact entries, each with 'contact' and optional 'existing_contact' keys. + * @param string $context Optional. The context of the sync. + * + * @return array|\WP_Error Per-contact results keyed by email (each true|\WP_Error), or WP_Error for total batch failure. + */ + public function push_contacts_data( $contacts, $context = '' ) { + $results = []; + foreach ( $contacts as $contact_data ) { + $email = $contact_data['contact']['email'] ?? ''; + $results[ $email ] = $this->push_contact_data( + $contact_data['contact'], + $context, + $contact_data['existing_contact'] ?? null + ); + } + return $results; + } + /** * Register data event handlers for this integration. * @@ -216,6 +240,24 @@ public function pull_contact_data( $user_id ) { return []; } + /** + * Pull contact data from the integration for multiple users. + * + * Default implementation iterates pull_contact_data() for each user. + * Integrations with native batch read APIs should override this method. + * + * @param int[] $user_ids Array of WordPress user IDs. + * + * @return array|\WP_Error Per-user results keyed by user ID (each array|\WP_Error), or WP_Error for total batch failure. + */ + public function pull_contacts_data( $user_ids ) { + $results = []; + foreach ( $user_ids as $user_id ) { + $results[ $user_id ] = $this->pull_contact_data( $user_id ); + } + return $results; + } + /** * Get incoming available contact fields from the integration. * diff --git a/includes/reader-activation/sync/class-contact-sync.php b/includes/reader-activation/sync/class-contact-sync.php index ac4b5cab62..9842df4c76 100644 --- a/includes/reader-activation/sync/class-contact-sync.php +++ b/includes/reader-activation/sync/class-contact-sync.php @@ -45,6 +45,11 @@ class Contact_Sync extends Sync { */ const RETRY_HOOK = 'newspack_contact_sync_retry'; + /** + * ActionScheduler hook for retrying a failed bulk integration sync. + */ + const BULK_RETRY_HOOK = 'newspack_bulk_contact_sync_retry'; + /** * Maximum number of retries for a failed integration sync. */ @@ -63,6 +68,7 @@ public static function init_hooks() { add_action( 'newspack_scheduled_esp_sync', [ __CLASS__, 'scheduled_sync' ], 10, 2 ); add_action( 'shutdown', [ __CLASS__, 'run_queued_syncs' ] ); add_action( self::RETRY_HOOK, [ __CLASS__, 'execute_integration_retry' ] ); + add_action( self::BULK_RETRY_HOOK, [ __CLASS__, 'execute_bulk_retry' ] ); add_action( 'action_scheduler_begin_execute', [ __CLASS__, 'set_current_as_action_id' ] ); add_action( 'action_scheduler_after_execute', [ __CLASS__, 'clear_current_as_action_id' ] ); add_filter( 'newspack_action_scheduler_hook_labels', [ __CLASS__, 'register_hook_labels' ] ); @@ -75,7 +81,8 @@ public static function init_hooks() { * @return array */ public static function register_hook_labels( $labels ) { - $labels[ self::RETRY_HOOK ] = __( 'Contact Sync Retry', 'newspack-plugin' ); + $labels[ self::RETRY_HOOK ] = __( 'Contact Sync Retry', 'newspack-plugin' ); + $labels[ self::BULK_RETRY_HOOK ] = __( 'Bulk Contact Sync Retry', 'newspack-plugin' ); return $labels; } @@ -226,6 +233,298 @@ private static function push_to_integrations( $contact, $context, $existing_cont return true; } + /** + * Push contact data for multiple users to all active integrations in bulk. + * + * Each integration receives all contacts at once via push_contacts_data(), + * allowing integrations with native batch APIs to handle them efficiently. + * + * @param int[] $user_ids Array of WordPress user IDs. + * @param string $context The context of the sync. + * + * @return true|\WP_Error True if all succeeded, or WP_Error with combined messages. + */ + public static function bulk_push_to_integrations( $user_ids, $context = '' ) { + $can_sync = static::can_sync( true ); + if ( $can_sync->has_errors() ) { + return $can_sync; + } + + if ( empty( $context ) ) { + $context = static::$context; + } + + $integrations = Integrations::get_active_integrations(); + $errors = []; + + // Build contact data once (integration-agnostic). + $contact_map = []; // Maps email => contact data. + $user_id_map = []; // Maps email => user_id for retry scheduling. + + foreach ( $user_ids as $user_id ) { + $user = \get_userdata( $user_id ); + if ( ! $user ) { + static::log( sprintf( 'Bulk push skipping non-existent user %d.', $user_id ) ); + continue; + } + + $contact_data = self::get_contact_data( $user_id ); + if ( \is_wp_error( $contact_data ) || empty( $contact_data['email'] ) ) { + static::log( sprintf( 'Bulk push skipping user %d: %s', $user_id, \is_wp_error( $contact_data ) ? $contact_data->get_error_message() : 'empty email' ) ); + continue; + } + + /** This filter is documented in includes/reader-activation/sync/class-contact-sync.php */ + $contact_data = \apply_filters( 'newspack_esp_sync_contact', $contact_data, $context ); + + $email = $contact_data['email']; + $contact_map[ $email ] = $contact_data; + $user_id_map[ $email ] = $user_id; + } + + if ( empty( $contact_map ) ) { + return true; + } + + foreach ( $integrations as $integration_id => $integration ) { + // Build per-integration contact list (only prepare_contact is integration-specific). + $contacts = []; + foreach ( $contact_map as $email => $contact_data ) { + $contacts[] = [ + 'contact' => $integration->prepare_contact( $contact_data ), + 'existing_contact' => null, + ]; + } + + static::log( sprintf( 'Bulk pushing %d contact(s) to integration "%s".', count( $contacts ), $integration_id ) ); + + $results = $integration->push_contacts_data( $contacts, $context ); + + if ( \is_wp_error( $results ) ) { + // Total batch failure — schedule bulk retry. + do_action( + 'newspack_sync_contact_failed', + [ + 'integration_id' => $integration_id, + 'contact' => null, + 'context' => $context, + 'reason' => $results->get_error_message(), + 'bulk' => true, + 'user_count' => count( $contacts ), + ] + ); + self::schedule_bulk_retry( $integration_id, array_values( $user_id_map ), $context, 0, $results ); + $errors[] = sprintf( '[%s] Batch failed: %s', $integration_id, $results->get_error_message() ); + } else { + // Per-contact results — schedule individual retries for failures. + foreach ( $results as $email => $result ) { + if ( \is_wp_error( $result ) ) { + $failed_user_id = $user_id_map[ $email ] ?? 0; + do_action( + 'newspack_sync_contact_failed', + [ + 'integration_id' => $integration_id, + 'contact' => $contact_map[ $email ] ?? [ 'email' => $email ], + 'context' => $context, + 'reason' => $result->get_error_message(), + ] + ); + self::schedule_integration_retry( $integration_id, $failed_user_id, $context, 0, $result ); + $errors[] = sprintf( '[%s] %s: %s', $integration_id, $email, $result->get_error_message() ); + } + } + } + } + + if ( ! empty( $errors ) ) { + return new \WP_Error( 'newspack_bulk_sync_failed', implode( '; ', $errors ) ); + } + + return true; + } + + /** + * Schedule a retry for a failed bulk integration sync via ActionScheduler. + * + * @param string $integration_id The integration ID. + * @param int[] $user_ids The WordPress user IDs. + * @param string $context The sync context. + * @param int $retry_count Current retry count (0 = first failure). + * @param \WP_Error $error The error from the failure. + */ + private static function schedule_bulk_retry( $integration_id, $user_ids, $context, $retry_count, $error ) { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + return; + } + + $error_message = $error->get_error_message(); + $next_retry = $retry_count + 1; + + if ( $next_retry > self::MAX_RETRIES ) { + static::log( + sprintf( + 'Max retries (%d) reached for bulk sync of integration "%s" (%d users). Giving up. Last error: %s', + self::MAX_RETRIES, + $integration_id, + count( $user_ids ), + $error_message + ) + ); + /** + * Fires when a bulk contact sync has exhausted all retry attempts. + * + * @param array $alert_data { + * Alert data. + * + * @type string $integration_id The integration that failed. + * @type int[] $user_ids The user IDs that failed to sync. + * @type string $context The sync context. + * @type string $reason The final error message. + * } + */ + do_action( + 'newspack_bulk_sync_retry_exhausted', + [ + 'integration_id' => $integration_id, + 'user_ids' => $user_ids, + 'context' => $context, + 'reason' => $error_message, + ] + ); + return; + } + + $backoff_index = min( $retry_count, count( self::RETRY_BACKOFF ) - 1 ); + $backoff_seconds = self::RETRY_BACKOFF[ $backoff_index ]; + + $retry_data = [ + 'integration_id' => $integration_id, + 'user_ids' => $user_ids, + 'context' => $context, + 'retry_count' => $next_retry, + 'reason' => $error_message, + ]; + + \as_schedule_single_action( + time() + $backoff_seconds, + self::BULK_RETRY_HOOK, + [ $retry_data ], + Integrations::get_action_group( $integration_id ) + ); + + static::log( + sprintf( + 'Scheduled bulk retry %d/%d for integration "%s" (%d users) in %ds. Error: %s', + $next_retry, + self::MAX_RETRIES, + $integration_id, + count( $user_ids ), + $backoff_seconds, + $error_message + ) + ); + } + + /** + * Execute a bulk integration sync retry from ActionScheduler. + * + * Rebuilds contact data from user IDs and calls push_contacts_data(). + * Filters out non-existent users. On total failure, reschedules the bulk retry. + * On per-contact results, schedules individual retries for failures. + * + * @param array $retry_data The retry data containing integration_id, user_ids, context, and retry_count. + * + * @throws \Exception When the final retry fails, so ActionScheduler marks the action as "failed". + */ + public static function execute_bulk_retry( $retry_data ) { + if ( ! is_array( $retry_data ) || empty( $retry_data['integration_id'] ) || empty( $retry_data['user_ids'] ) ) { + Logger::log( 'Invalid bulk retry data received from Action Scheduler.', 'NEWSPACK-SYNC', 'error' ); + return; + } + + $integration_id = $retry_data['integration_id']; + $user_ids = $retry_data['user_ids']; + $context = $retry_data['context'] ?? static::$context; + $retry_count = $retry_data['retry_count'] ?? 1; + + $integration = Integrations::get_integration( $integration_id ); + if ( ! $integration ) { + Logger::log( sprintf( 'Integration "%s" not found on bulk retry %d.', $integration_id, $retry_count ), 'NEWSPACK-SYNC', 'error' ); + return; + } + + // Build contacts, filtering out stale user IDs. + $contacts = []; + $user_id_map = []; + + foreach ( $user_ids as $user_id ) { + $user = \get_userdata( $user_id ); + if ( ! $user ) { + continue; + } + + $contact_data = self::get_contact_data( $user_id ); + if ( \is_wp_error( $contact_data ) || empty( $contact_data['email'] ) ) { + continue; + } + + /** This filter is documented in includes/reader-activation/sync/class-contact-sync.php */ + $contact_data = \apply_filters( 'newspack_esp_sync_contact', $contact_data, $context ); + + $email = $contact_data['email']; + $user_id_map[ $email ] = $user_id; + $contacts[] = [ + 'contact' => $integration->prepare_contact( $contact_data ), + 'existing_contact' => null, + ]; + } + + if ( empty( $contacts ) ) { + static::log( sprintf( 'Bulk retry %d for integration "%s": no valid users remaining.', $retry_count, $integration_id ) ); + return; + } + + static::log( sprintf( 'Executing bulk retry %d/%d for integration "%s" (%d contacts).', $retry_count, self::MAX_RETRIES, $integration_id, count( $contacts ) ) ); + + $results = $integration->push_contacts_data( $contacts, $context ); + + if ( \is_wp_error( $results ) ) { + // Total failure again — reschedule bulk retry. + static::log( sprintf( 'Bulk retry %d failed for integration "%s": %s', $retry_count, $integration_id, $results->get_error_message() ) ); + self::schedule_bulk_retry( $integration_id, array_values( $user_id_map ), $context, $retry_count, $results ); + + if ( $retry_count >= self::MAX_RETRIES ) { + throw new \Exception( + esc_html( + sprintf( + 'Bulk retry %d/%d failed for integration "%s" (%d contacts): %s', + $retry_count, + self::MAX_RETRIES, + $integration_id, + count( $contacts ), + $results->get_error_message() + ) + ) + ); + } + } else { + // Per-contact results — schedule individual retries for any failures. + $failures = 0; + foreach ( $results as $email => $result ) { + if ( \is_wp_error( $result ) ) { + $failed_user_id = $user_id_map[ $email ] ?? 0; + self::schedule_integration_retry( $integration_id, $failed_user_id, $context, 0, $result ); + $failures++; + } + } + if ( $failures > 0 ) { + static::log( sprintf( 'Bulk retry %d for integration "%s": %d/%d contacts failed, scheduled individual retries.', $retry_count, $integration_id, $failures, count( $contacts ) ) ); + } else { + static::log( sprintf( 'Bulk retry %d for integration "%s": all %d contacts succeeded.', $retry_count, $integration_id, count( $contacts ) ) ); + } + } + } + /** * Schedule a retry for a failed integration sync via ActionScheduler. * diff --git a/tests/unit-tests/integrations/class-test-integrations.php b/tests/unit-tests/integrations/class-test-integrations.php index cbfad5705c..a9e3ed241c 100644 --- a/tests/unit-tests/integrations/class-test-integrations.php +++ b/tests/unit-tests/integrations/class-test-integrations.php @@ -12,6 +12,7 @@ use Newspack\Reader_Activation\Integrations; use Newspack\Reader_Activation\Integrations\Contact_Cron; use Newspack\Reader_Activation\Integrations\Contact_Pull; +use Newspack\Reader_Activation\Integrations\Incoming_Field; use Sample_Integration; /** @@ -954,6 +955,98 @@ public function pull_contact_data( $user_id ) { $this->assertSame( wp_json_encode( 'ajax_value' ), $stored ); } + /** + * Test push_contacts_data default implementation iterates push_contact_data. + */ + public function test_push_contacts_data_default_loops() { + require_once __DIR__ . '/class-failing-sample-integration.php'; + \Failing_Sample_Integration::reset(); + $integration = new \Failing_Sample_Integration( 'bulk_test', 'Bulk Test' ); + + $contacts = [ + [ + 'contact' => [ + 'email' => 'a@test.com', + 'metadata' => [], + ], + 'existing_contact' => null, + ], + [ + 'contact' => [ + 'email' => 'b@test.com', + 'metadata' => [], + ], + 'existing_contact' => null, + ], + [ + 'contact' => [ + 'email' => 'c@test.com', + 'metadata' => [], + ], + 'existing_contact' => null, + ], + ]; + + $results = $integration->push_contacts_data( $contacts, 'Test' ); + + $this->assertIsArray( $results ); + $this->assertCount( 3, $results ); + $this->assertTrue( $results['a@test.com'] ); + $this->assertTrue( $results['b@test.com'] ); + $this->assertTrue( $results['c@test.com'] ); + $this->assertEquals( 3, \Failing_Sample_Integration::$push_count ); + } + + /** + * Test push_contacts_data returns per-contact errors on partial failure. + */ + public function test_push_contacts_data_partial_failure() { + require_once __DIR__ . '/class-failing-sample-integration.php'; + \Failing_Sample_Integration::reset(); + + // Create a subclass that fails only for specific emails. + $integration = new class( 'partial_fail', 'Partial Fail' ) extends \Failing_Sample_Integration { + /** + * Push contact data, failing for specific emails. + * + * @param array $contact The contact data. + * @param string $context The sync context. + * @param array|null $existing_contact Existing contact data. + * @return true|\WP_Error + */ + public function push_contact_data( $contact, $context = '', $existing_contact = null ) { + self::$push_count++; + if ( 'fail@test.com' === ( $contact['email'] ?? '' ) ) { + return new \WP_Error( 'mock_error', 'Mock push failed' ); + } + return true; + } + }; + + $contacts = [ + [ + 'contact' => [ + 'email' => 'ok@test.com', + 'metadata' => [], + ], + 'existing_contact' => null, + ], + [ + 'contact' => [ + 'email' => 'fail@test.com', + 'metadata' => [], + ], + 'existing_contact' => null, + ], + ]; + + $results = $integration->push_contacts_data( $contacts, 'Test' ); + + $this->assertIsArray( $results ); + $this->assertTrue( $results['ok@test.com'] ); + $this->assertWPError( $results['fail@test.com'] ); + } + /** * Test get_action_group returns prefixed integration ID. */ @@ -1007,4 +1100,305 @@ public function test_data_events_get_handler_action_group_filtered() { $group = Data_Events::get_handler_action_group( Sample_Integration::class, $action_name ); $this->assertSame( 'newspack-integration-filtered-id', $group ); } + + /** + * Test pull_contacts_data default implementation iterates pull_contact_data. + */ + public function test_pull_contacts_data_default_loops() { + $integration = new class( 'bulk_pull_test', 'Bulk Pull Test' ) extends Sample_Integration { + /** + * Pull contact data for a user. + * + * @param int $user_id User ID. + * @return array Contact data. + */ + public function pull_contact_data( $user_id ) { + return [ 'org' => 'Newspack-' . $user_id ]; + } + }; + + $user1 = $this->factory()->user->create(); + $user2 = $this->factory()->user->create(); + $user3 = $this->factory()->user->create(); + + $results = $integration->pull_contacts_data( [ $user1, $user2, $user3 ] ); + + $this->assertIsArray( $results ); + $this->assertCount( 3, $results ); + $this->assertEquals( [ 'org' => 'Newspack-' . $user1 ], $results[ $user1 ] ); + $this->assertEquals( [ 'org' => 'Newspack-' . $user2 ], $results[ $user2 ] ); + $this->assertEquals( [ 'org' => 'Newspack-' . $user3 ], $results[ $user3 ] ); + } + + /** + * Test bulk_pull_from_integrations pulls and stores data for all users. + */ + public function test_bulk_pull_from_integrations_success() { + $integration = new class( 'bulk_pull', 'Bulk Pull' ) extends Sample_Integration { + /** + * Pull contact data for a user. + * + * @param int $user_id User ID. + * @return array + */ + public function pull_contact_data( $user_id ) { + return [ 'org' => 'Newspack-' . $user_id ]; + } + + /** + * Get available incoming fields. + * + * @return array + */ + public function get_available_incoming_fields() { + return [ + new Incoming_Field( 'org', 'Organization', 'text' ), + ]; + } + }; + Integrations::register( $integration ); + Integrations::enable( 'bulk_pull' ); + $integration->update_enabled_incoming_fields( [ 'org' ] ); + Integrations::disable( 'esp' ); + + $user1 = $this->factory()->user->create(); + $user2 = $this->factory()->user->create(); + + $result = Contact_Pull::bulk_pull_from_integrations( [ $user1, $user2 ] ); + + $this->assertTrue( $result ); + $this->assertSame( wp_json_encode( 'Newspack-' . $user1 ), get_user_meta( $user1, 'newspack_reader_data_item_org', true ) ); + $this->assertSame( wp_json_encode( 'Newspack-' . $user2 ), get_user_meta( $user2, 'newspack_reader_data_item_org', true ) ); + } + + /** + * Test bulk_pull_from_integrations schedules individual retries for per-user failures. + */ + public function test_bulk_pull_per_user_failure_retries() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + + $integration = new class( 'bulk_pull_fail', 'Bulk Pull Fail' ) extends Sample_Integration { + /** + * Pull contact data returning error. + * + * @param int $user_id User ID. + * @return \WP_Error + */ + public function pull_contact_data( $user_id ) { + return new \WP_Error( 'pull_error', 'Pull failed for ' . $user_id ); + } + + /** + * Get available incoming fields. + * + * @return array + */ + public function get_available_incoming_fields() { + return [ + new Incoming_Field( 'org', 'Organization', 'text' ), + ]; + } + }; + Integrations::register( $integration ); + Integrations::enable( 'bulk_pull_fail' ); + $integration->update_enabled_incoming_fields( [ 'org' ] ); + Integrations::disable( 'esp' ); + + as_unschedule_all_actions( Contact_Pull::RETRY_HOOK ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'bpf1@test.com' ] ); + $user2 = $this->factory()->user->create( [ 'user_email' => 'bpf2@test.com' ] ); + + $result = Contact_Pull::bulk_pull_from_integrations( [ $user1, $user2 ] ); + + $this->assertWPError( $result ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Pull::RETRY_HOOK, + 'group' => Integrations::get_action_group( 'bulk_pull_fail' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertCount( 2, $pending, 'Individual retries should be scheduled for each failed user.' ); + } + + /** + * Test that a total batch failure schedules a bulk pull retry. + */ + public function test_bulk_pull_retry_scheduling() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + + $integration = new class( 'batch_pull_fail', 'Batch Pull Fail' ) extends Sample_Integration { + /** + * Pull contacts data returning batch error. + * + * @param int[] $user_ids User IDs. + * @return \WP_Error + */ + public function pull_contacts_data( $user_ids ) { + return new \WP_Error( 'batch_error', 'Batch pull API failed' ); + } + + /** + * Get available incoming fields. + * + * @return array + */ + public function get_available_incoming_fields() { + return [ + new Incoming_Field( 'org', 'Organization', 'text' ), + ]; + } + }; + Integrations::register( $integration ); + Integrations::enable( 'batch_pull_fail' ); + $integration->update_enabled_incoming_fields( [ 'org' ] ); + Integrations::disable( 'esp' ); + + as_unschedule_all_actions( Contact_Pull::BULK_RETRY_HOOK ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'bpr1@test.com' ] ); + $user2 = $this->factory()->user->create( [ 'user_email' => 'bpr2@test.com' ] ); + + $result = Contact_Pull::bulk_pull_from_integrations( [ $user1, $user2 ] ); + + $this->assertWPError( $result ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Pull::BULK_RETRY_HOOK, + 'group' => Integrations::get_action_group( 'batch_pull_fail' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertCount( 1, $pending, 'A bulk retry should be scheduled for total batch failure.' ); + + $action_id = array_key_first( $pending ); + $action = \ActionScheduler::store()->fetch_action( $action_id ); + $args = $action->get_args()[0]; + $this->assertEquals( 'batch_pull_fail', $args['integration_id'] ); + $this->assertCount( 2, $args['user_ids'] ); + $this->assertEquals( 1, $args['retry_count'] ); + } + + /** + * Test execute_bulk_retry re-pulls and stores data on success. + */ + public function test_bulk_pull_retry_execution_success() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + + $integration = new class( 'bulk_pull_retry_ok', 'Bulk Pull Retry OK' ) extends Sample_Integration { + /** + * Number of pull calls. + * + * @var int + */ + public static $pull_count = 0; + + /** + * Pull contact data for a user. + * + * @param int $user_id User ID. + * @return array + */ + public function pull_contact_data( $user_id ) { + self::$pull_count++; + return [ 'org' => 'Retry-' . $user_id ]; + } + + /** + * Get available incoming fields. + * + * @return array + */ + public function get_available_incoming_fields() { + return [ + new Incoming_Field( 'org', 'Organization', 'text' ), + ]; + } + }; + Integrations::register( $integration ); + Integrations::enable( 'bulk_pull_retry_ok' ); + $integration->update_enabled_incoming_fields( [ 'org' ] ); + + $user1 = $this->factory()->user->create(); + $user2 = $this->factory()->user->create(); + + as_unschedule_all_actions( Contact_Pull::BULK_RETRY_HOOK ); + + Contact_Pull::execute_bulk_retry( + [ + 'integration_id' => 'bulk_pull_retry_ok', + 'user_ids' => [ $user1, $user2 ], + 'retry_count' => 1, + ] + ); + + $this->assertEquals( 2, $integration::$pull_count, 'Both users should be pulled on retry.' ); + $this->assertSame( wp_json_encode( 'Retry-' . $user1 ), get_user_meta( $user1, 'newspack_reader_data_item_org', true ) ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Pull::BULK_RETRY_HOOK, + 'group' => Integrations::get_action_group( 'bulk_pull_retry_ok' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertEmpty( $pending, 'No retry should be scheduled on success.' ); + } + + /** + * Test bulk_pull_from_integrations skips integrations with no enabled incoming fields. + */ + public function test_bulk_pull_skips_no_incoming_fields() { + $integration = new Sample_Integration( 'bulk_pull_skip', 'Bulk Pull Skip' ); + Integrations::register( $integration ); + Integrations::enable( 'bulk_pull_skip' ); + Integrations::disable( 'esp' ); + + $user = $this->factory()->user->create(); + + $result = Contact_Pull::bulk_pull_from_integrations( [ $user ] ); + + $this->assertTrue( $result, 'Should return true when all integrations are skipped.' ); + } + + /** + * Test pull_contacts_data returns per-user errors on partial failure. + */ + public function test_pull_contacts_data_partial_failure() { + $bad_user_id = 99999; + $integration = new class( 'pull_partial', 'Pull Partial' ) extends Sample_Integration { + /** + * Pull contact data for a user. + * + * @param int $user_id User ID. + * @return array|\WP_Error Contact data or error. + */ + public function pull_contact_data( $user_id ) { + if ( ! get_userdata( $user_id ) ) { + return new \WP_Error( 'not_found', 'User not found' ); + } + return [ 'org' => 'Test' ]; + } + }; + + $valid_user = $this->factory()->user->create(); + + $results = $integration->pull_contacts_data( [ $valid_user, $bad_user_id ] ); + + $this->assertIsArray( $results ); + $this->assertEquals( [ 'org' => 'Test' ], $results[ $valid_user ] ); + $this->assertWPError( $results[ $bad_user_id ] ); + } } diff --git a/tests/unit-tests/reader-activation-sync.php b/tests/unit-tests/reader-activation-sync.php index a7962d3d04..31d2d5b727 100644 --- a/tests/unit-tests/reader-activation-sync.php +++ b/tests/unit-tests/reader-activation-sync.php @@ -613,4 +613,296 @@ public function test_integration_retry_invalid_data() { ); $this->assertEmpty( $pending, 'No retry should be scheduled for invalid data.' ); } + + /** + * Test that a total batch failure schedules a bulk retry. + */ + public function test_bulk_retry_scheduling() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + + require_once __DIR__ . '/integrations/class-failing-sample-integration.php'; + $integration = new class( 'batch_fail', 'Batch Fail' ) extends Failing_Sample_Integration { + /** + * Always return a batch error. + * + * @param array $contacts Contacts to push. + * @param string $context Sync context. + * @return \WP_Error + */ + public function push_contacts_data( $contacts, $context = '' ) { + return new \WP_Error( 'batch_error', 'Batch API failed' ); + } + }; + Integrations::register( $integration ); + Integrations::enable( 'batch_fail' ); + + // Disable ESP so only our mock runs. + Integrations::disable( 'esp' ); + + as_unschedule_all_actions( Contact_Sync::BULK_RETRY_HOOK ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'br1@test.com' ] ); + $user2 = $this->factory()->user->create( [ 'user_email' => 'br2@test.com' ] ); + + $result = Contact_Sync::bulk_push_to_integrations( [ $user1, $user2 ], 'Batch fail test' ); + + $this->assertWPError( $result ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Sync::BULK_RETRY_HOOK, + 'group' => Integrations::get_action_group( 'batch_fail' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertCount( 1, $pending, 'A bulk retry should be scheduled for total batch failure.' ); + + $action_id = array_key_first( $pending ); + $action = \ActionScheduler::store()->fetch_action( $action_id ); + $args = $action->get_args()[0]; + $this->assertEquals( 'batch_fail', $args['integration_id'] ); + $this->assertCount( 2, $args['user_ids'] ); + $this->assertEquals( 1, $args['retry_count'] ); + } + + /** + * Test execute_bulk_retry re-pushes contacts and succeeds. + */ + public function test_bulk_retry_execution_success() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + + Failing_Sample_Integration::reset(); + $this->register_failing_integration( 'bulk_retry_exec' ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'bre1@test.com' ] ); + $user2 = $this->factory()->user->create( [ 'user_email' => 'bre2@test.com' ] ); + + as_unschedule_all_actions( Contact_Sync::BULK_RETRY_HOOK ); + + Contact_Sync::execute_bulk_retry( + [ + 'integration_id' => 'bulk_retry_exec', + 'user_ids' => [ $user1, $user2 ], + 'context' => 'Retry test', + 'retry_count' => 1, + ] + ); + + $this->assertEquals( 2, Failing_Sample_Integration::$push_count, 'Both contacts should be pushed on retry.' ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Sync::BULK_RETRY_HOOK, + 'group' => Integrations::get_action_group( 'bulk_retry_exec' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertEmpty( $pending, 'No retry should be scheduled on success.' ); + } + + /** + * Test execute_bulk_retry reschedules on repeated total failure. + */ + public function test_bulk_retry_reschedules_on_failure() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + + require_once __DIR__ . '/integrations/class-failing-sample-integration.php'; + $integration = new class( 'bulk_refail', 'Bulk Refail' ) extends Failing_Sample_Integration { + /** + * Always return a batch error. + * + * @param array $contacts Contacts to push. + * @param string $context Sync context. + * @return \WP_Error + */ + public function push_contacts_data( $contacts, $context = '' ) { + return new \WP_Error( 'batch_error', 'Still failing' ); + } + }; + Integrations::register( $integration ); + Integrations::enable( 'bulk_refail' ); + + as_unschedule_all_actions( Contact_Sync::BULK_RETRY_HOOK ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'rf1@test.com' ] ); + + Contact_Sync::execute_bulk_retry( + [ + 'integration_id' => 'bulk_refail', + 'user_ids' => [ $user1 ], + 'context' => 'Refail test', + 'retry_count' => 2, + ] + ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Sync::BULK_RETRY_HOOK, + 'group' => Integrations::get_action_group( 'bulk_refail' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertCount( 1, $pending, 'Another bulk retry should be scheduled.' ); + + $action_id = array_key_first( $pending ); + $action = \ActionScheduler::store()->fetch_action( $action_id ); + $args = $action->get_args()[0]; + $this->assertEquals( 3, $args['retry_count'], 'Retry count should be incremented.' ); + } + + /** + * Test bulk retry fires exhaustion hook at max retries. + */ + public function test_bulk_retry_max_retries_exhaustion() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + + require_once __DIR__ . '/integrations/class-failing-sample-integration.php'; + $integration = new class( 'bulk_max', 'Bulk Max' ) extends Failing_Sample_Integration { + /** + * Always return a batch error. + * + * @param array $contacts Contacts to push. + * @param string $context Sync context. + * @return \WP_Error + */ + public function push_contacts_data( $contacts, $context = '' ) { + return new \WP_Error( 'batch_error', 'Permanent failure' ); + } + }; + Integrations::register( $integration ); + Integrations::enable( 'bulk_max' ); + + as_unschedule_all_actions( Contact_Sync::BULK_RETRY_HOOK ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'max1@test.com' ] ); + + $exhausted_fired = false; + $hook_callback = function () use ( &$exhausted_fired ) { + $exhausted_fired = true; + }; + add_action( 'newspack_bulk_sync_retry_exhausted', $hook_callback ); + + $threw = false; + try { + Contact_Sync::execute_bulk_retry( + [ + 'integration_id' => 'bulk_max', + 'user_ids' => [ $user1 ], + 'context' => 'Max test', + 'retry_count' => Contact_Sync::MAX_RETRIES, + ] + ); + } catch ( \Exception $e ) { + $threw = true; + } + + $this->assertTrue( $threw, 'Should throw on final retry failure.' ); + $this->assertTrue( $exhausted_fired, 'Exhaustion hook should fire.' ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Sync::BULK_RETRY_HOOK, + 'group' => Integrations::get_action_group( 'bulk_max' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertEmpty( $pending, 'No further retries should be scheduled.' ); + + remove_action( 'newspack_bulk_sync_retry_exhausted', $hook_callback ); + } + + /** + * Test bulk_push_to_integrations pushes all users to integration. + */ + public function test_bulk_push_to_integrations_success() { + if ( ! defined( 'NEWSPACK_ALLOW_READER_SYNC' ) ) { + define( 'NEWSPACK_ALLOW_READER_SYNC', true ); + } + + Integrations::disable( 'esp' ); + Failing_Sample_Integration::reset(); + $this->register_failing_integration( 'bulk_mock' ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'bulk1@test.com' ] ); + $user2 = $this->factory()->user->create( [ 'user_email' => 'bulk2@test.com' ] ); + + $result = Contact_Sync::bulk_push_to_integrations( [ $user1, $user2 ], 'Bulk test' ); + + $this->assertTrue( $result ); + $this->assertEquals( 2, Failing_Sample_Integration::$push_count ); + + Integrations::enable( 'esp' ); + } + + /** + * Test bulk_push_to_integrations schedules individual retries for per-contact failures. + */ + public function test_bulk_push_per_contact_failure_retries() { + if ( ! function_exists( 'as_schedule_single_action' ) ) { + $this->markTestSkipped( 'ActionScheduler not available.' ); + } + if ( ! defined( 'NEWSPACK_ALLOW_READER_SYNC' ) ) { + define( 'NEWSPACK_ALLOW_READER_SYNC', true ); + } + + Integrations::disable( 'esp' ); + Failing_Sample_Integration::reset(); + Failing_Sample_Integration::$should_fail = true; + $this->register_failing_integration( 'bulk_fail' ); + + as_unschedule_all_actions( Contact_Sync::RETRY_HOOK ); + + $user1 = $this->factory()->user->create( [ 'user_email' => 'bfail1@test.com' ] ); + $user2 = $this->factory()->user->create( [ 'user_email' => 'bfail2@test.com' ] ); + + $result = Contact_Sync::bulk_push_to_integrations( [ $user1, $user2 ], 'Bulk fail test' ); + + $this->assertWPError( $result ); + + $pending = as_get_scheduled_actions( + [ + 'hook' => Contact_Sync::RETRY_HOOK, + 'group' => Integrations::get_action_group( 'bulk_fail' ), + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ], + 'ARRAY_A' + ); + $this->assertCount( 2, $pending, 'Individual retries should be scheduled for each failed contact.' ); + + Integrations::enable( 'esp' ); + } + + /** + * Test bulk_push_to_integrations skips non-existent users. + */ + public function test_bulk_push_skips_invalid_users() { + if ( ! defined( 'NEWSPACK_ALLOW_READER_SYNC' ) ) { + define( 'NEWSPACK_ALLOW_READER_SYNC', true ); + } + + Integrations::disable( 'esp' ); + Failing_Sample_Integration::reset(); + $this->register_failing_integration( 'bulk_skip' ); + + $valid_user = $this->factory()->user->create( [ 'user_email' => 'valid@test.com' ] ); + + $result = Contact_Sync::bulk_push_to_integrations( [ $valid_user, 99999 ], 'Skip test' ); + + $this->assertTrue( $result ); + $this->assertEquals( 1, Failing_Sample_Integration::$push_count, 'Only valid users should be pushed.' ); + + Integrations::enable( 'esp' ); + } }