From a3b405bd87c1b0b8554c50897b6d8a5c030359a2 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Tue, 13 Jan 2026 12:32:33 +0000 Subject: [PATCH 01/16] feat: add Application layer services for push and pull syndication Add PushService and PullService to orchestrate syndication workflows: - PushService: handles push syndication with locking, state tracking, and support for create/update/delete operations - PullService: handles pull syndication with import mode, GUID-based deduplication, and batch processing Add immutable DTOs for operation results: - PushResult: success/failure/skipped states with remote ID tracking - PullResult: includes partial status for multi-post operations Create TransportFactoryInterface to enable proper unit testing through dependency inversion. Services now depend on the interface rather than the concrete factory class. All 204 unit tests pass. Co-Authored-By: Claude Opus 4.5 --- includes/Application/DTO/PullResult.php | 260 +++++++++++ includes/Application/DTO/PushResult.php | 190 ++++++++ includes/Application/Services/PullService.php | 367 ++++++++++++++++ includes/Application/Services/PushService.php | 326 ++++++++++++++ .../Contracts/TransportFactoryInterface.php | 63 +++ includes/Infrastructure/DI/Container.php | 19 +- .../Transport/TransportFactory.php | 3 +- tests/Unit/Application/DTO/PullResultTest.php | 202 +++++++++ tests/Unit/Application/DTO/PushResultTest.php | 152 +++++++ .../Application/Services/PullServiceTest.php | 413 ++++++++++++++++++ .../Application/Services/PushServiceTest.php | 339 ++++++++++++++ 11 files changed, 2324 insertions(+), 10 deletions(-) create mode 100644 includes/Application/DTO/PullResult.php create mode 100644 includes/Application/DTO/PushResult.php create mode 100644 includes/Application/Services/PullService.php create mode 100644 includes/Application/Services/PushService.php create mode 100644 includes/Domain/Contracts/TransportFactoryInterface.php create mode 100644 tests/Unit/Application/DTO/PullResultTest.php create mode 100644 tests/Unit/Application/DTO/PushResultTest.php create mode 100644 tests/Unit/Application/Services/PullServiceTest.php create mode 100644 tests/Unit/Application/Services/PushServiceTest.php diff --git a/includes/Application/DTO/PullResult.php b/includes/Application/DTO/PullResult.php new file mode 100644 index 0000000..078b956 --- /dev/null +++ b/includes/Application/DTO/PullResult.php @@ -0,0 +1,260 @@ + + */ + public readonly array $errors; + + /** + * Private constructor - use static factory methods. + * + * @param int $site_id Site ID. + * @param string $status Status. + * @param int $created Posts created. + * @param int $updated Posts updated. + * @param int $skipped Posts skipped. + * @param string $error_code Error code. + * @param string $message Message. + * @param array $errors Array of error messages. + */ + private function __construct( + int $site_id, + string $status, + int $created, + int $updated, + int $skipped, + string $error_code, + string $message, + array $errors + ) { + $this->site_id = $site_id; + $this->status = $status; + $this->created = $created; + $this->updated = $updated; + $this->skipped = $skipped; + $this->error_code = $error_code; + $this->message = $message; + $this->errors = $errors; + } + + /** + * Create a success result. + * + * @param int $site_id Site ID. + * @param int $created Number of posts created. + * @param int $updated Number of posts updated. + * @return self + */ + public static function success( int $site_id, int $created, int $updated ): self { + return new self( + $site_id, + self::STATUS_SUCCESS, + $created, + $updated, + 0, + '', + '', + array() + ); + } + + /** + * Create a failure result. + * + * @param int $site_id Site ID. + * @param string $error_code Error code. + * @param string $message Error message. + * @return self + */ + public static function failure( int $site_id, string $error_code, string $message ): self { + return new self( + $site_id, + self::STATUS_FAILURE, + 0, + 0, + 0, + $error_code, + $message, + array() + ); + } + + /** + * Create a skipped result. + * + * @param int $site_id Site ID. + * @param string $reason Reason for skipping. + * @return self + */ + public static function skipped( int $site_id, string $reason ): self { + return new self( + $site_id, + self::STATUS_SKIPPED, + 0, + 0, + 0, + '', + $reason, + array() + ); + } + + /** + * Create a partial success result. + * + * @param int $site_id Site ID. + * @param int $created Number of posts created. + * @param int $updated Number of posts updated. + * @param int $skipped Number of posts skipped. + * @param array $errors Array of error messages. + * @return self + */ + public static function partial( int $site_id, int $created, int $updated, int $skipped, array $errors ): self { + return new self( + $site_id, + self::STATUS_PARTIAL, + $created, + $updated, + $skipped, + '', + '', + $errors + ); + } + + /** + * Check if the result is successful. + * + * @return bool + */ + public function is_success(): bool { + return self::STATUS_SUCCESS === $this->status; + } + + /** + * Check if the result is a failure. + * + * @return bool + */ + public function is_failure(): bool { + return self::STATUS_FAILURE === $this->status; + } + + /** + * Check if the operation was skipped. + * + * @return bool + */ + public function is_skipped(): bool { + return self::STATUS_SKIPPED === $this->status; + } + + /** + * Check if the result is partial (some errors). + * + * @return bool + */ + public function is_partial(): bool { + return self::STATUS_PARTIAL === $this->status; + } + + /** + * Get the total number of posts processed. + * + * @return int + */ + public function get_total_processed(): int { + return $this->created + $this->updated + $this->skipped + count( $this->errors ); + } + + /** + * Convert to array for serialization. + * + * @return array + */ + public function to_array(): array { + return array( + 'site_id' => $this->site_id, + 'status' => $this->status, + 'created' => $this->created, + 'updated' => $this->updated, + 'skipped' => $this->skipped, + 'error_code' => $this->error_code, + 'message' => $this->message, + 'errors' => $this->errors, + ); + } +} diff --git a/includes/Application/DTO/PushResult.php b/includes/Application/DTO/PushResult.php new file mode 100644 index 0000000..7c60e4a --- /dev/null +++ b/includes/Application/DTO/PushResult.php @@ -0,0 +1,190 @@ +site_id = $site_id; + $this->status = $status; + $this->remote_id = $remote_id; + $this->error_code = $error_code; + $this->message = $message; + $this->action = $action; + } + + /** + * Create a success result. + * + * @param int $site_id Site ID. + * @param int $remote_id Remote post ID. + * @param string $action Action taken (created, updated, deleted). + * @return self + */ + public static function success( int $site_id, int $remote_id, string $action ): self { + return new self( + $site_id, + self::STATUS_SUCCESS, + $remote_id, + '', + '', + $action + ); + } + + /** + * Create a failure result. + * + * @param int $site_id Site ID. + * @param string $error_code Error code. + * @param string $message Error message. + * @return self + */ + public static function failure( int $site_id, string $error_code, string $message ): self { + return new self( + $site_id, + self::STATUS_FAILURE, + 0, + $error_code, + $message, + '' + ); + } + + /** + * Create a skipped result. + * + * @param int $site_id Site ID. + * @param string $reason Reason for skipping. + * @return self + */ + public static function skipped( int $site_id, string $reason ): self { + return new self( + $site_id, + self::STATUS_SKIPPED, + 0, + '', + $reason, + '' + ); + } + + /** + * Check if the result is successful. + * + * @return bool + */ + public function is_success(): bool { + return self::STATUS_SUCCESS === $this->status; + } + + /** + * Check if the result is a failure. + * + * @return bool + */ + public function is_failure(): bool { + return self::STATUS_FAILURE === $this->status; + } + + /** + * Check if the operation was skipped. + * + * @return bool + */ + public function is_skipped(): bool { + return self::STATUS_SKIPPED === $this->status; + } + + /** + * Convert to array for serialization. + * + * @return array + */ + public function to_array(): array { + return array( + 'site_id' => $this->site_id, + 'status' => $this->status, + 'remote_id' => $this->remote_id, + 'error_code' => $this->error_code, + 'message' => $this->message, + 'action' => $this->action, + ); + } +} diff --git a/includes/Application/Services/PullService.php b/includes/Application/Services/PullService.php new file mode 100644 index 0000000..4200280 --- /dev/null +++ b/includes/Application/Services/PullService.php @@ -0,0 +1,367 @@ +transport_factory = $transport_factory; + } + + /** + * Set whether to update existing posts. + * + * @param bool $update Whether to update. + * @return self + */ + public function set_update_existing( bool $update ): self { + $this->update_existing = $update; + return $this; + } + + /** + * Pull content from multiple sites. + * + * @param int[] $site_ids Array of site post IDs. + * @return PullResult[] Array of results keyed by site ID. + */ + public function pull_from_sites( array $site_ids ): array { + $this->begin_import(); + + try { + $results = array(); + + foreach ( $site_ids as $site_id ) { + $results[ $site_id ] = $this->pull_from_site( $site_id ); + } + + return $results; + } finally { + $this->end_import(); + } + } + + /** + * Pull content from a single site. + * + * @param int $site_id The site post ID. + * @return PullResult The pull result. + */ + public function pull_from_site( int $site_id ): PullResult { + $site = get_post( $site_id ); + + if ( ! $site instanceof WP_Post || 'syn_site' !== $site->post_type ) { + return PullResult::failure( $site_id, 'invalid_site', 'Site not found.' ); + } + + // Check if site is enabled. + $enabled = get_post_meta( $site_id, 'syn_site_enabled', true ); + if ( 'on' !== $enabled ) { + return PullResult::skipped( $site_id, 'Site is disabled.' ); + } + + $transport = $this->transport_factory->create_pull_transport( $site_id ); + + if ( ! $transport instanceof PullTransportInterface ) { + return PullResult::failure( $site_id, 'invalid_transport', 'Could not create pull transport.' ); + } + + // Pull posts from remote site. + $posts = $transport->pull(); + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + $posts = apply_filters( 'syn_pre_pull_posts', $posts, $site, null ); + + if ( empty( $posts ) ) { + $this->update_last_pull_time( $site_id ); + return PullResult::success( $site_id, 0, 0 ); + } + + $created = 0; + $updated = 0; + $skipped = 0; + $errors = array(); + + foreach ( $posts as $post_data ) { + $result = $this->process_pulled_post( $post_data, $site_id, $site ); + + switch ( $result['status'] ) { + case 'created': + ++$created; + break; + case 'updated': + ++$updated; + break; + case 'skipped': + ++$skipped; + break; + case 'error': + $errors[] = $result['message']; + break; + } + } + + $this->update_last_pull_time( $site_id ); + + if ( ! empty( $errors ) ) { + return PullResult::partial( $site_id, $created, $updated, $skipped, $errors ); + } + + return PullResult::success( $site_id, $created, $updated ); + } + + /** + * Process a single pulled post. + * + * @param array $post_data Pulled post data. + * @param int $site_id Site post ID. + * @param WP_Post $site Site post object. + * @return array{status: string, message: string, post_id: int} + */ + private function process_pulled_post( array $post_data, int $site_id, WP_Post $site ): array { + // Require a GUID for identification. + if ( empty( $post_data['post_guid'] ) ) { + return array( + 'status' => 'error', + 'message' => 'Post missing GUID.', + 'post_id' => 0, + ); + } + + $existing_id = $this->find_post_by_guid( $post_data['post_guid'] ); + + if ( $existing_id ) { + return $this->update_existing_post( $existing_id, $post_data, $site_id, $site ); + } + + return $this->create_new_post( $post_data, $site_id, $site ); + } + + /** + * Create a new post from pulled data. + * + * @param array $post_data Pulled post data. + * @param int $site_id Site post ID. + * @param WP_Post $site Site post object. + * @return array{status: string, message: string, post_id: int} + */ + private function create_new_post( array $post_data, int $site_id, WP_Post $site ): array { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + $shortcircuit = apply_filters( 'syn_pre_pull_new_post_shortcircuit', false, $post_data, $site, '', null ); + + if ( true === $shortcircuit ) { + return array( + 'status' => 'skipped', + 'message' => 'Filtered out.', + 'post_id' => 0, + ); + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + $post_data = apply_filters( 'syn_pull_new_post', $post_data, $site, null ); + + // Prepare data for wp_insert_post. + $insert_data = $this->prepare_insert_data( $post_data ); + $result = wp_insert_post( $insert_data, true ); + + if ( is_wp_error( $result ) ) { + return array( + 'status' => 'error', + 'message' => $result->get_error_message(), + 'post_id' => 0, + ); + } + + $post_id = (int) $result; + + // Store syndication metadata. + update_post_meta( $post_id, 'syn_post_guid', $post_data['post_guid'] ); + update_post_meta( $post_id, 'syn_source_site_id', $site_id ); + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + do_action( 'syn_post_pull_new_post', $post_id, $post_data, $site, '', null ); + + return array( + 'status' => 'created', + 'message' => '', + 'post_id' => $post_id, + ); + } + + /** + * Update an existing post from pulled data. + * + * @param int $post_id Existing post ID. + * @param array $post_data Pulled post data. + * @param int $site_id Site post ID. + * @param WP_Post $site Site post object. + * @return array{status: string, message: string, post_id: int} + */ + private function update_existing_post( int $post_id, array $post_data, int $site_id, WP_Post $site ): array { + if ( ! $this->update_existing ) { + return array( + 'status' => 'skipped', + 'message' => 'Update disabled.', + 'post_id' => $post_id, + ); + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + $shortcircuit = apply_filters( 'syn_pre_pull_edit_post_shortcircuit', false, $post_data, $site, '', null ); + + if ( true === $shortcircuit ) { + return array( + 'status' => 'skipped', + 'message' => 'Filtered out.', + 'post_id' => $post_id, + ); + } + + $post_data['ID'] = $post_id; + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + $post_data = apply_filters( 'syn_pull_edit_post', $post_data, $site, null ); + + // Prepare data for wp_update_post. + $update_data = $this->prepare_insert_data( $post_data ); + $update_data['ID'] = $post_id; + + $result = wp_update_post( $update_data, true ); + + if ( is_wp_error( $result ) ) { + return array( + 'status' => 'error', + 'message' => $result->get_error_message(), + 'post_id' => $post_id, + ); + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + do_action( 'syn_post_pull_edit_post', $post_id, $post_data, $site, '', null ); + + return array( + 'status' => 'updated', + 'message' => '', + 'post_id' => $post_id, + ); + } + + /** + * Prepare data for wp_insert_post/wp_update_post. + * + * @param array $post_data Pulled post data. + * @return array Data for WordPress post functions. + */ + private function prepare_insert_data( array $post_data ): array { + $data = array( + 'post_title' => $post_data['post_title'] ?? '', + 'post_content' => $post_data['post_content'] ?? '', + 'post_excerpt' => $post_data['post_excerpt'] ?? '', + 'post_status' => $post_data['post_status'] ?? 'draft', + 'post_type' => $post_data['post_type'] ?? 'post', + ); + + if ( ! empty( $post_data['post_date'] ) ) { + $data['post_date'] = $post_data['post_date']; + } + + if ( ! empty( $post_data['post_date_gmt'] ) ) { + $data['post_date_gmt'] = $post_data['post_date_gmt']; + } + + if ( isset( $post_data['ID'] ) ) { + $data['ID'] = $post_data['ID']; + } + + return $data; + } + + /** + * Find a post by its syndication GUID. + * + * @param string $guid The syndication GUID. + * @return int|null Post ID or null. + */ + private function find_post_by_guid( string $guid ): ?int { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom meta lookup, caching not beneficial here. + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'syn_post_guid' AND meta_value = %s LIMIT 1", + $guid + ) + ); + + return $post_id ? (int) $post_id : null; + } + + /** + * Update the last pull time for a site. + * + * @param int $site_id Site post ID. + */ + private function update_last_pull_time( int $site_id ): void { + update_post_meta( $site_id, 'syn_last_pull_time', time() ); + } + + /** + * Begin import mode. + */ + private function begin_import(): void { + if ( ! defined( 'WP_IMPORTING' ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress core constant. + define( 'WP_IMPORTING', true ); + } + + wp_defer_term_counting( true ); + wp_defer_comment_counting( true ); + wp_suspend_cache_invalidation( true ); + } + + /** + * End import mode. + */ + private function end_import(): void { + wp_suspend_cache_invalidation( false ); + wp_defer_term_counting( false ); + wp_defer_comment_counting( false ); + } +} diff --git a/includes/Application/Services/PushService.php b/includes/Application/Services/PushService.php new file mode 100644 index 0000000..ffcdd7f --- /dev/null +++ b/includes/Application/Services/PushService.php @@ -0,0 +1,326 @@ +transport_factory = $transport_factory; + } + + /** + * Push a post to multiple sites. + * + * @param int $post_id The local post ID. + * @param int[] $site_ids Array of site post IDs to push to. + * @return PushResult[] Array of results keyed by site ID. + */ + public function push_to_sites( int $post_id, array $site_ids ): array { + $post = get_post( $post_id ); + + if ( ! $post instanceof WP_Post ) { + return array(); + } + + if ( ! $this->acquire_lock() ) { + return array(); + } + + try { + $results = array(); + $slave_states = $this->get_slave_post_states( $post_id ); + + foreach ( $site_ids as $site_id ) { + $results[ $site_id ] = $this->push_to_site( $post_id, $site_id, $slave_states ); + } + + $this->save_slave_post_states( $post_id, $slave_states ); + + return $results; + } finally { + $this->release_lock(); + } + } + + /** + * Push a post to a single site. + * + * @param int $post_id The local post ID. + * @param int $site_id The site post ID. + * @param array|null $slave_states Optional slave states array (modified by reference). + * @return PushResult The push result. + */ + public function push_to_site( int $post_id, int $site_id, ?array &$slave_states = null ): PushResult { + $transport = $this->transport_factory->create_push_transport( $site_id ); + + if ( ! $transport instanceof PushTransportInterface ) { + return PushResult::failure( $site_id, 'invalid_transport', 'Could not create push transport.' ); + } + + if ( null === $slave_states ) { + $slave_states = $this->get_slave_post_states( $post_id ); + } + + $state = $this->get_site_state( $site_id, $slave_states ); + $remote_id = $this->get_remote_post_id( $site_id, $slave_states ); + + // Determine if this is a new push or update. + if ( $this->is_new_push( $state ) ) { + $result = $this->do_new_push( $transport, $post_id, $site_id, $slave_states ); + } else { + $result = $this->do_update_push( $transport, $post_id, $site_id, $remote_id, $slave_states ); + } + + return $result; + } + + /** + * Delete a post from a site. + * + * @param int $post_id The local post ID. + * @param int $site_id The site post ID. + * @return PushResult The delete result. + */ + public function delete_from_site( int $post_id, int $site_id ): PushResult { + $transport = $this->transport_factory->create_push_transport( $site_id ); + + if ( ! $transport instanceof PushTransportInterface ) { + return PushResult::failure( $site_id, 'invalid_transport', 'Could not create push transport.' ); + } + + $slave_states = $this->get_slave_post_states( $post_id ); + $remote_id = $this->get_remote_post_id( $site_id, $slave_states ); + + if ( 0 === $remote_id ) { + return PushResult::failure( $site_id, 'no_remote_post', 'Post does not exist on remote site.' ); + } + + $result = $transport->delete( $remote_id ); + + if ( is_wp_error( $result ) ) { + $slave_states['remove-error'][ $site_id ] = $result; + $this->save_slave_post_states( $post_id, $slave_states ); + + return PushResult::failure( $site_id, $result->get_error_code(), $result->get_error_message() ); + } + + // Remove from success tracking. + unset( $slave_states['success'][ $site_id ] ); + $this->save_slave_post_states( $post_id, $slave_states ); + + return PushResult::success( $site_id, $remote_id, 'deleted' ); + } + + /** + * Perform a new push operation. + * + * @param PushTransportInterface $transport The transport. + * @param int $post_id Local post ID. + * @param int $site_id Site post ID. + * @param array $slave_states Slave states (modified by reference). + * @return PushResult The result. + */ + private function do_new_push( + PushTransportInterface $transport, + int $post_id, + int $site_id, + array &$slave_states + ): PushResult { + $result = $transport->push( $post_id ); + + if ( true === $result ) { + // Filtered out - not an error. + return PushResult::skipped( $site_id, 'Filtered out by pre-push filter.' ); + } + + if ( is_wp_error( $result ) ) { + $slave_states['new-error'][ $site_id ] = $result; + + return PushResult::failure( $site_id, $result->get_error_code(), $result->get_error_message() ); + } + + // Success - store the remote ID. + $remote_id = (int) $result; + $slave_states['success'][ $site_id ] = $remote_id; + + // Clear any previous errors. + unset( $slave_states['new-error'][ $site_id ] ); + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + do_action( 'syn_post_push_new_post', $remote_id, $post_id, get_post( $site_id ), '', null, array() ); + + return PushResult::success( $site_id, $remote_id, 'created' ); + } + + /** + * Perform an update push operation. + * + * @param PushTransportInterface $transport The transport. + * @param int $post_id Local post ID. + * @param int $site_id Site post ID. + * @param int $remote_id Remote post ID. + * @param array $slave_states Slave states (modified by reference). + * @return PushResult The result. + */ + private function do_update_push( + PushTransportInterface $transport, + int $post_id, + int $site_id, + int $remote_id, + array &$slave_states + ): PushResult { + $result = $transport->update( $post_id, $remote_id ); + + if ( true === $result ) { + // Filtered out - not an error. + return PushResult::skipped( $site_id, 'Filtered out by pre-update filter.' ); + } + + if ( is_wp_error( $result ) ) { + $slave_states['edit-error'][ $site_id ] = $result; + + return PushResult::failure( $site_id, $result->get_error_code(), $result->get_error_message() ); + } + + // Clear any previous errors. + unset( $slave_states['edit-error'][ $site_id ] ); + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. + do_action( 'syn_post_push_edit_post', $result, $post_id, get_post( $site_id ), '', null, array() ); + + return PushResult::success( $site_id, $remote_id, 'updated' ); + } + + /** + * Get slave post states from post meta. + * + * @param int $post_id The post ID. + * @return array Slave states. + */ + private function get_slave_post_states( int $post_id ): array { + $states = get_post_meta( $post_id, '_syn_slave_post_states', true ); + return is_array( $states ) ? $states : array(); + } + + /** + * Save slave post states to post meta. + * + * @param int $post_id The post ID. + * @param array $states Slave states. + */ + private function save_slave_post_states( int $post_id, array $states ): void { + update_post_meta( $post_id, '_syn_slave_post_states', $states ); + } + + /** + * Get the state for a specific site. + * + * @param int $site_id Site post ID. + * @param array $slave_states Slave states. + * @return string State identifier. + */ + private function get_site_state( int $site_id, array $slave_states ): string { + if ( isset( $slave_states['success'][ $site_id ] ) ) { + return 'success'; + } + + if ( isset( $slave_states['edit-error'][ $site_id ] ) ) { + return 'edit-error'; + } + + if ( isset( $slave_states['remove-error'][ $site_id ] ) ) { + return 'remove-error'; + } + + if ( isset( $slave_states['new-error'][ $site_id ] ) ) { + return 'new-error'; + } + + return 'new'; + } + + /** + * Get the remote post ID for a site. + * + * @param int $site_id Site post ID. + * @param array $slave_states Slave states. + * @return int Remote post ID or 0. + */ + private function get_remote_post_id( int $site_id, array $slave_states ): int { + return isset( $slave_states['success'][ $site_id ] ) + ? (int) $slave_states['success'][ $site_id ] + : 0; + } + + /** + * Check if this should be a new push. + * + * @param string $state Current state. + * @return bool True if new push, false for update. + */ + private function is_new_push( string $state ): bool { + return in_array( $state, array( 'new', 'new-error' ), true ); + } + + /** + * Acquire the syndication lock. + * + * @return bool True if lock acquired. + */ + private function acquire_lock(): bool { + if ( 'locked' === get_transient( self::LOCK_TRANSIENT ) ) { + return false; + } + + set_transient( self::LOCK_TRANSIENT, 'locked', self::LOCK_DURATION ); + return true; + } + + /** + * Release the syndication lock. + */ + private function release_lock(): void { + delete_transient( self::LOCK_TRANSIENT ); + } +} diff --git a/includes/Domain/Contracts/TransportFactoryInterface.php b/includes/Domain/Contracts/TransportFactoryInterface.php new file mode 100644 index 0000000..c433b9d --- /dev/null +++ b/includes/Domain/Contracts/TransportFactoryInterface.php @@ -0,0 +1,63 @@ +, name: string}> + */ + public function get_available_transports(): array; + + /** + * Check if a transport type supports push. + * + * @param string $transport_type Transport type identifier. + * @return bool True if push is supported. + */ + public function supports_push( string $transport_type ): bool; + + /** + * Check if a transport type supports pull. + * + * @param string $transport_type Transport type identifier. + * @return bool True if pull is supported. + */ + public function supports_pull( string $transport_type ): bool; +} diff --git a/includes/Infrastructure/DI/Container.php b/includes/Infrastructure/DI/Container.php index 6463fee..1d4cabb 100644 --- a/includes/Infrastructure/DI/Container.php +++ b/includes/Infrastructure/DI/Container.php @@ -11,6 +11,7 @@ use Automattic\Syndication\Domain\Contracts\EncryptorInterface; use Automattic\Syndication\Domain\Contracts\SiteRepositoryInterface; +use Automattic\Syndication\Domain\Contracts\TransportFactoryInterface; use Automattic\Syndication\Infrastructure\Encryption\OpenSSLEncryptor; use Automattic\Syndication\Infrastructure\Repositories\SiteRepository; use Automattic\Syndication\Infrastructure\Transport\TransportFactory; @@ -153,14 +154,14 @@ static function (): HookManager { } ); - // Transport factory. - $this->register( - TransportFactory::class, - function ( Container $container ): TransportFactory { - $encryptor = $container->get( EncryptorInterface::class ); - \assert( $encryptor instanceof EncryptorInterface ); - return new TransportFactory( $encryptor ); - } - ); + // Transport factory - register both interface and concrete class. + $factory_callback = function ( Container $container ): TransportFactory { + $encryptor = $container->get( EncryptorInterface::class ); + \assert( $encryptor instanceof EncryptorInterface ); + return new TransportFactory( $encryptor ); + }; + + $this->register( TransportFactory::class, $factory_callback ); + $this->register( TransportFactoryInterface::class, $factory_callback ); } } diff --git a/includes/Infrastructure/Transport/TransportFactory.php b/includes/Infrastructure/Transport/TransportFactory.php index b05e6d7..3a0736e 100644 --- a/includes/Infrastructure/Transport/TransportFactory.php +++ b/includes/Infrastructure/Transport/TransportFactory.php @@ -12,6 +12,7 @@ use Automattic\Syndication\Domain\Contracts\EncryptorInterface; use Automattic\Syndication\Domain\Contracts\PullTransportInterface; use Automattic\Syndication\Domain\Contracts\PushTransportInterface; +use Automattic\Syndication\Domain\Contracts\TransportFactoryInterface; use Automattic\Syndication\Domain\Contracts\TransportInterface; use Automattic\Syndication\Infrastructure\Transport\Feed\RSSFeedTransport; use Automattic\Syndication\Infrastructure\Transport\REST\WordPressComTransport; @@ -24,7 +25,7 @@ * Creates the appropriate transport implementation based on site metadata, * handling credential decryption and configuration. */ -final class TransportFactory { +final class TransportFactory implements TransportFactoryInterface { /** * Transport type to class mapping. diff --git a/tests/Unit/Application/DTO/PullResultTest.php b/tests/Unit/Application/DTO/PullResultTest.php new file mode 100644 index 0000000..deac166 --- /dev/null +++ b/tests/Unit/Application/DTO/PullResultTest.php @@ -0,0 +1,202 @@ +assertEquals( 123, $result->site_id ); + $this->assertEquals( PullResult::STATUS_SUCCESS, $result->status ); + $this->assertEquals( 5, $result->created ); + $this->assertEquals( 3, $result->updated ); + $this->assertEquals( 0, $result->skipped ); + $this->assertEquals( '', $result->error_code ); + $this->assertEquals( '', $result->message ); + $this->assertEquals( array(), $result->errors ); + } + + /** + * Test failure factory method. + */ + public function test_failure_creates_correct_result(): void { + $result = PullResult::failure( 123, 'invalid_site', 'Site not found.' ); + + $this->assertEquals( 123, $result->site_id ); + $this->assertEquals( PullResult::STATUS_FAILURE, $result->status ); + $this->assertEquals( 0, $result->created ); + $this->assertEquals( 0, $result->updated ); + $this->assertEquals( 0, $result->skipped ); + $this->assertEquals( 'invalid_site', $result->error_code ); + $this->assertEquals( 'Site not found.', $result->message ); + $this->assertEquals( array(), $result->errors ); + } + + /** + * Test skipped factory method. + */ + public function test_skipped_creates_correct_result(): void { + $result = PullResult::skipped( 123, 'Site is disabled.' ); + + $this->assertEquals( 123, $result->site_id ); + $this->assertEquals( PullResult::STATUS_SKIPPED, $result->status ); + $this->assertEquals( 0, $result->created ); + $this->assertEquals( 0, $result->updated ); + $this->assertEquals( 0, $result->skipped ); + $this->assertEquals( '', $result->error_code ); + $this->assertEquals( 'Site is disabled.', $result->message ); + $this->assertEquals( array(), $result->errors ); + } + + /** + * Test partial factory method. + */ + public function test_partial_creates_correct_result(): void { + $errors = array( 'Post 1 missing GUID.', 'Post 2 failed insert.' ); + $result = PullResult::partial( 123, 3, 2, 1, $errors ); + + $this->assertEquals( 123, $result->site_id ); + $this->assertEquals( PullResult::STATUS_PARTIAL, $result->status ); + $this->assertEquals( 3, $result->created ); + $this->assertEquals( 2, $result->updated ); + $this->assertEquals( 1, $result->skipped ); + $this->assertEquals( '', $result->error_code ); + $this->assertEquals( '', $result->message ); + $this->assertEquals( $errors, $result->errors ); + } + + /** + * Test is_success returns true for success status. + */ + public function test_is_success_returns_true_for_success(): void { + $result = PullResult::success( 123, 1, 1 ); + + $this->assertTrue( $result->is_success() ); + $this->assertFalse( $result->is_failure() ); + $this->assertFalse( $result->is_skipped() ); + $this->assertFalse( $result->is_partial() ); + } + + /** + * Test is_failure returns true for failure status. + */ + public function test_is_failure_returns_true_for_failure(): void { + $result = PullResult::failure( 123, 'error', 'Error message' ); + + $this->assertFalse( $result->is_success() ); + $this->assertTrue( $result->is_failure() ); + $this->assertFalse( $result->is_skipped() ); + $this->assertFalse( $result->is_partial() ); + } + + /** + * Test is_skipped returns true for skipped status. + */ + public function test_is_skipped_returns_true_for_skipped(): void { + $result = PullResult::skipped( 123, 'Reason' ); + + $this->assertFalse( $result->is_success() ); + $this->assertFalse( $result->is_failure() ); + $this->assertTrue( $result->is_skipped() ); + $this->assertFalse( $result->is_partial() ); + } + + /** + * Test is_partial returns true for partial status. + */ + public function test_is_partial_returns_true_for_partial(): void { + $result = PullResult::partial( 123, 1, 1, 1, array( 'error' ) ); + + $this->assertFalse( $result->is_success() ); + $this->assertFalse( $result->is_failure() ); + $this->assertFalse( $result->is_skipped() ); + $this->assertTrue( $result->is_partial() ); + } + + /** + * Test get_total_processed calculates correctly. + */ + public function test_get_total_processed(): void { + $result = PullResult::partial( 123, 3, 2, 1, array( 'error1', 'error2' ) ); + + // 3 created + 2 updated + 1 skipped + 2 errors = 8 + $this->assertEquals( 8, $result->get_total_processed() ); + } + + /** + * Test get_total_processed for success result. + */ + public function test_get_total_processed_for_success(): void { + $result = PullResult::success( 123, 5, 3 ); + + // 5 created + 3 updated = 8 + $this->assertEquals( 8, $result->get_total_processed() ); + } + + /** + * Test to_array returns correct structure. + */ + public function test_to_array_returns_correct_structure(): void { + $errors = array( 'error1', 'error2' ); + $result = PullResult::partial( 123, 3, 2, 1, $errors ); + $array = $result->to_array(); + + $this->assertArrayHasKey( 'site_id', $array ); + $this->assertArrayHasKey( 'status', $array ); + $this->assertArrayHasKey( 'created', $array ); + $this->assertArrayHasKey( 'updated', $array ); + $this->assertArrayHasKey( 'skipped', $array ); + $this->assertArrayHasKey( 'error_code', $array ); + $this->assertArrayHasKey( 'message', $array ); + $this->assertArrayHasKey( 'errors', $array ); + + $this->assertEquals( 123, $array['site_id'] ); + $this->assertEquals( 'partial', $array['status'] ); + $this->assertEquals( 3, $array['created'] ); + $this->assertEquals( 2, $array['updated'] ); + $this->assertEquals( 1, $array['skipped'] ); + $this->assertEquals( $errors, $array['errors'] ); + } + + /** + * Test status constants have expected values. + */ + public function test_status_constants(): void { + $this->assertEquals( 'success', PullResult::STATUS_SUCCESS ); + $this->assertEquals( 'failure', PullResult::STATUS_FAILURE ); + $this->assertEquals( 'skipped', PullResult::STATUS_SKIPPED ); + $this->assertEquals( 'partial', PullResult::STATUS_PARTIAL ); + } + + /** + * Test success with zero counts. + */ + public function test_success_with_zero_counts(): void { + $result = PullResult::success( 123, 0, 0 ); + + $this->assertTrue( $result->is_success() ); + $this->assertEquals( 0, $result->created ); + $this->assertEquals( 0, $result->updated ); + $this->assertEquals( 0, $result->get_total_processed() ); + } +} diff --git a/tests/Unit/Application/DTO/PushResultTest.php b/tests/Unit/Application/DTO/PushResultTest.php new file mode 100644 index 0000000..ce81cd7 --- /dev/null +++ b/tests/Unit/Application/DTO/PushResultTest.php @@ -0,0 +1,152 @@ +assertEquals( 123, $result->site_id ); + $this->assertEquals( PushResult::STATUS_SUCCESS, $result->status ); + $this->assertEquals( 456, $result->remote_id ); + $this->assertEquals( 'created', $result->action ); + $this->assertEquals( '', $result->error_code ); + $this->assertEquals( '', $result->message ); + } + + /** + * Test failure factory method. + */ + public function test_failure_creates_correct_result(): void { + $result = PushResult::failure( 123, 'connection_error', 'Could not connect.' ); + + $this->assertEquals( 123, $result->site_id ); + $this->assertEquals( PushResult::STATUS_FAILURE, $result->status ); + $this->assertEquals( 0, $result->remote_id ); + $this->assertEquals( '', $result->action ); + $this->assertEquals( 'connection_error', $result->error_code ); + $this->assertEquals( 'Could not connect.', $result->message ); + } + + /** + * Test skipped factory method. + */ + public function test_skipped_creates_correct_result(): void { + $result = PushResult::skipped( 123, 'Site is disabled.' ); + + $this->assertEquals( 123, $result->site_id ); + $this->assertEquals( PushResult::STATUS_SKIPPED, $result->status ); + $this->assertEquals( 0, $result->remote_id ); + $this->assertEquals( '', $result->action ); + $this->assertEquals( '', $result->error_code ); + $this->assertEquals( 'Site is disabled.', $result->message ); + } + + /** + * Test is_success returns true for success status. + */ + public function test_is_success_returns_true_for_success(): void { + $result = PushResult::success( 123, 456, 'updated' ); + + $this->assertTrue( $result->is_success() ); + $this->assertFalse( $result->is_failure() ); + $this->assertFalse( $result->is_skipped() ); + } + + /** + * Test is_failure returns true for failure status. + */ + public function test_is_failure_returns_true_for_failure(): void { + $result = PushResult::failure( 123, 'error', 'Error message' ); + + $this->assertFalse( $result->is_success() ); + $this->assertTrue( $result->is_failure() ); + $this->assertFalse( $result->is_skipped() ); + } + + /** + * Test is_skipped returns true for skipped status. + */ + public function test_is_skipped_returns_true_for_skipped(): void { + $result = PushResult::skipped( 123, 'Reason' ); + + $this->assertFalse( $result->is_success() ); + $this->assertFalse( $result->is_failure() ); + $this->assertTrue( $result->is_skipped() ); + } + + /** + * Test to_array returns correct structure. + */ + public function test_to_array_returns_correct_structure(): void { + $result = PushResult::success( 123, 456, 'created' ); + $array = $result->to_array(); + + $this->assertArrayHasKey( 'site_id', $array ); + $this->assertArrayHasKey( 'status', $array ); + $this->assertArrayHasKey( 'remote_id', $array ); + $this->assertArrayHasKey( 'error_code', $array ); + $this->assertArrayHasKey( 'message', $array ); + $this->assertArrayHasKey( 'action', $array ); + + $this->assertEquals( 123, $array['site_id'] ); + $this->assertEquals( 'success', $array['status'] ); + $this->assertEquals( 456, $array['remote_id'] ); + $this->assertEquals( 'created', $array['action'] ); + } + + /** + * Test status constants have expected values. + */ + public function test_status_constants(): void { + $this->assertEquals( 'success', PushResult::STATUS_SUCCESS ); + $this->assertEquals( 'failure', PushResult::STATUS_FAILURE ); + $this->assertEquals( 'skipped', PushResult::STATUS_SKIPPED ); + } + + /** + * Test success with different actions. + * + * @dataProvider action_provider + * + * @param string $action The action to test. + */ + public function test_success_with_various_actions( string $action ): void { + $result = PushResult::success( 1, 2, $action ); + + $this->assertEquals( $action, $result->action ); + } + + /** + * Provide action values for testing. + * + * @return array> + */ + public static function action_provider(): array { + return array( + 'created' => array( 'created' ), + 'updated' => array( 'updated' ), + 'deleted' => array( 'deleted' ), + ); + } +} diff --git a/tests/Unit/Application/Services/PullServiceTest.php b/tests/Unit/Application/Services/PullServiceTest.php new file mode 100644 index 0000000..c7ec450 --- /dev/null +++ b/tests/Unit/Application/Services/PullServiceTest.php @@ -0,0 +1,413 @@ +factory = Mockery::mock( TransportFactoryInterface::class ); + $this->service = new PullService( $this->factory ); + } + + /** + * Test pull_from_site returns failure when site not found. + */ + public function test_pull_from_site_returns_failure_when_site_not_found(): void { + Functions\when( 'get_post' )->justReturn( null ); + + $result = $this->service->pull_from_site( 123 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'invalid_site', $result->error_code ); + } + + /** + * Test pull_from_site returns failure when wrong post type. + */ + public function test_pull_from_site_returns_failure_when_wrong_post_type(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'post'; + + Functions\when( 'get_post' )->justReturn( $post ); + + $result = $this->service->pull_from_site( 123 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'invalid_site', $result->error_code ); + } + + /** + * Test pull_from_site returns skipped when site disabled. + */ + public function test_pull_from_site_returns_skipped_when_site_disabled(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) { + return match ( $key ) { + 'syn_site_enabled' => 'off', + default => '', + }; + } + ); + + $result = $this->service->pull_from_site( 123 ); + + $this->assertTrue( $result->is_skipped() ); + $this->assertStringContainsString( 'disabled', $result->message ); + } + + /** + * Test pull_from_site returns failure when transport creation fails. + */ + public function test_pull_from_site_returns_failure_when_no_transport(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) { + return match ( $key ) { + 'syn_site_enabled' => 'on', + default => '', + }; + } + ); + + $this->factory + ->shouldReceive( 'create_pull_transport' ) + ->with( 123 ) + ->andReturn( null ); + + $result = $this->service->pull_from_site( 123 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'invalid_transport', $result->error_code ); + } + + /** + * Test pull_from_site returns success with zero posts. + */ + public function test_pull_from_site_returns_success_with_no_posts(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + $transport = Mockery::mock( PullTransportInterface::class ); + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) { + return match ( $key ) { + 'syn_site_enabled' => 'on', + default => '', + }; + } + ); + Functions\when( 'apply_filters' )->alias( + function ( $hook, $value ) { + return $value; + } + ); + Functions\when( 'update_post_meta' )->justReturn( true ); + + $this->factory + ->shouldReceive( 'create_pull_transport' ) + ->with( 123 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'pull' ) + ->andReturn( array() ); + + $result = $this->service->pull_from_site( 123 ); + + $this->assertTrue( $result->is_success() ); + $this->assertEquals( 0, $result->created ); + $this->assertEquals( 0, $result->updated ); + } + + /** + * Test pull_from_site creates new posts. + * + * Note: This test requires global $wpdb, which is only available + * in integration tests. This test verifies the service setup and + * transport interaction, with actual post creation tested in integration. + */ + public function test_pull_from_site_creates_new_posts(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + $transport = Mockery::mock( PullTransportInterface::class ); + + $pulled_posts = array( + array( + 'post_guid' => 'guid-123', + 'post_title' => 'Test Post', + 'post_content' => 'Test content.', + 'post_status' => 'publish', + ), + ); + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) { + return match ( $key ) { + 'syn_site_enabled' => 'on', + default => '', + }; + } + ); + Functions\when( 'apply_filters' )->alias( + function ( $hook, $value ) { + return $value; + } + ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'wp_insert_post' )->justReturn( 456 ); + Functions\when( 'do_action' )->justReturn( null ); + + Functions\when( 'wp_defer_term_counting' )->justReturn( null ); + Functions\when( 'wp_defer_comment_counting' )->justReturn( null ); + Functions\when( 'wp_suspend_cache_invalidation' )->justReturn( true ); + + $this->factory + ->shouldReceive( 'create_pull_transport' ) + ->with( 123 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'pull' ) + ->andReturn( $pulled_posts ); + + // Mock global $wpdb for the find_post_by_guid call. + global $wpdb; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Mocking for unit test. + $wpdb = Mockery::mock( 'wpdb' ); + $wpdb->postmeta = 'wp_postmeta'; + $wpdb->shouldReceive( 'prepare' ) + ->andReturn( "SELECT post_id FROM wp_postmeta WHERE meta_key = 'syn_post_guid' AND meta_value = 'guid-123' LIMIT 1" ); + $wpdb->shouldReceive( 'get_var' ) + ->andReturn( null ); // No existing post. + + $result = $this->service->pull_from_site( 123 ); + + $this->assertTrue( $result->is_success() ); + $this->assertEquals( 1, $result->created ); + } + + /** + * Test pull_from_site handles posts without GUID. + */ + public function test_pull_from_site_handles_posts_without_guid(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + $transport = Mockery::mock( PullTransportInterface::class ); + + $pulled_posts = array( + array( + 'post_title' => 'No GUID Post', + 'post_content' => 'Content without GUID.', + ), + ); + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) { + return match ( $key ) { + 'syn_site_enabled' => 'on', + default => '', + }; + } + ); + Functions\when( 'apply_filters' )->alias( + function ( $hook, $value ) { + return $value; + } + ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'wp_defer_term_counting' )->justReturn( null ); + Functions\when( 'wp_defer_comment_counting' )->justReturn( null ); + Functions\when( 'wp_suspend_cache_invalidation' )->justReturn( true ); + + $this->factory + ->shouldReceive( 'create_pull_transport' ) + ->with( 123 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'pull' ) + ->andReturn( $pulled_posts ); + + $result = $this->service->pull_from_site( 123 ); + + // Should have partial success with error for missing GUID. + $this->assertTrue( $result->is_partial() ); + $this->assertNotEmpty( $result->errors ); + } + + /** + * Test set_update_existing returns self for chaining. + */ + public function test_set_update_existing_returns_self(): void { + $result = $this->service->set_update_existing( false ); + + $this->assertSame( $this->service, $result ); + } + + /** + * Test pull_from_sites calls pull_from_site for each site. + */ + public function test_pull_from_sites_processes_multiple_sites(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + $transport = Mockery::mock( PullTransportInterface::class ); + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) { + return match ( $key ) { + 'syn_site_enabled' => 'on', + default => '', + }; + } + ); + Functions\when( 'apply_filters' )->alias( + function ( $hook, $value ) { + return $value; + } + ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'wp_defer_term_counting' )->justReturn( null ); + Functions\when( 'wp_defer_comment_counting' )->justReturn( null ); + Functions\when( 'wp_suspend_cache_invalidation' )->justReturn( true ); + + $this->factory + ->shouldReceive( 'create_pull_transport' ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'pull' ) + ->andReturn( array() ); + + $results = $this->service->pull_from_sites( array( 1, 2, 3 ) ); + + $this->assertCount( 3, $results ); + $this->assertArrayHasKey( 1, $results ); + $this->assertArrayHasKey( 2, $results ); + $this->assertArrayHasKey( 3, $results ); + } + + /** + * Test pull_from_site handles insert error. + */ + public function test_pull_from_site_handles_insert_error(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + $transport = Mockery::mock( PullTransportInterface::class ); + + $pulled_posts = array( + array( + 'post_guid' => 'guid-123', + 'post_title' => 'Test Post', + 'post_content' => 'Test content.', + ), + ); + + $wp_error = new \WP_Error( 'insert_failed', 'Could not insert post.' ); + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) { + return match ( $key ) { + 'syn_site_enabled' => 'on', + default => '', + }; + } + ); + Functions\when( 'apply_filters' )->alias( + function ( $hook, $value ) { + return $value; + } + ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'wp_insert_post' )->justReturn( $wp_error ); + Functions\when( 'is_wp_error' )->alias( + function ( $thing ) { + return $thing instanceof \WP_Error; + } + ); + Functions\when( 'wp_defer_term_counting' )->justReturn( null ); + Functions\when( 'wp_defer_comment_counting' )->justReturn( null ); + Functions\when( 'wp_suspend_cache_invalidation' )->justReturn( true ); + + $this->factory + ->shouldReceive( 'create_pull_transport' ) + ->with( 123 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'pull' ) + ->andReturn( $pulled_posts ); + + // Mock global $wpdb for the find_post_by_guid call. + global $wpdb; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Mocking for unit test. + $wpdb = Mockery::mock( 'wpdb' ); + $wpdb->postmeta = 'wp_postmeta'; + $wpdb->shouldReceive( 'prepare' ) + ->andReturn( "SELECT post_id FROM wp_postmeta WHERE meta_key = 'syn_post_guid' AND meta_value = 'guid-123' LIMIT 1" ); + $wpdb->shouldReceive( 'get_var' ) + ->andReturn( null ); // No existing post. + + $result = $this->service->pull_from_site( 123 ); + + $this->assertTrue( $result->is_partial() ); + $this->assertNotEmpty( $result->errors ); + } +} diff --git a/tests/Unit/Application/Services/PushServiceTest.php b/tests/Unit/Application/Services/PushServiceTest.php new file mode 100644 index 0000000..ea3229a --- /dev/null +++ b/tests/Unit/Application/Services/PushServiceTest.php @@ -0,0 +1,339 @@ +factory = Mockery::mock( TransportFactoryInterface::class ); + $this->service = new PushService( $this->factory ); + } + + /** + * Test push_to_sites returns empty when post not found. + */ + public function test_push_to_sites_returns_empty_when_post_not_found(): void { + Functions\when( 'get_post' )->justReturn( null ); + + $results = $this->service->push_to_sites( 123, array( 1, 2 ) ); + + $this->assertEmpty( $results ); + } + + /** + * Test push_to_sites returns empty when lock cannot be acquired. + */ + public function test_push_to_sites_returns_empty_when_locked(): void { + $post = Mockery::mock( WP_Post::class ); + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_transient' )->justReturn( 'locked' ); + + $results = $this->service->push_to_sites( 123, array( 1, 2 ) ); + + $this->assertEmpty( $results ); + } + + /** + * Test push_to_site returns failure when transport creation fails. + */ + public function test_push_to_site_returns_failure_when_no_transport(): void { + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( null ); + + Functions\when( 'get_post_meta' )->justReturn( array() ); + + $result = $this->service->push_to_site( 123, 456 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'invalid_transport', $result->error_code ); + } + + /** + * Test push_to_site creates new post successfully. + */ + public function test_push_to_site_creates_new_post_successfully(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + $site = Mockery::mock( WP_Post::class ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'push' ) + ->with( 123 ) + ->andReturn( 789 ); + + Functions\when( 'get_post_meta' )->justReturn( array() ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'get_post' )->justReturn( $site ); + Functions\when( 'do_action' )->justReturn( null ); + + $result = $this->service->push_to_site( 123, 456 ); + + $this->assertTrue( $result->is_success() ); + $this->assertEquals( 789, $result->remote_id ); + $this->assertEquals( 'created', $result->action ); + } + + /** + * Test push_to_site updates existing post successfully. + */ + public function test_push_to_site_updates_existing_post_successfully(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + $site = Mockery::mock( WP_Post::class ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'update' ) + ->with( 123, 789 ) + ->andReturn( 789 ); + + // Existing slave state with success. + $slave_states = array( + 'success' => array( 456 => 789 ), + ); + + Functions\when( 'get_post_meta' )->justReturn( $slave_states ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'get_post' )->justReturn( $site ); + Functions\when( 'do_action' )->justReturn( null ); + + $result = $this->service->push_to_site( 123, 456 ); + + $this->assertTrue( $result->is_success() ); + $this->assertEquals( 789, $result->remote_id ); + $this->assertEquals( 'updated', $result->action ); + } + + /** + * Test push_to_site handles skipped (filtered) push. + */ + public function test_push_to_site_handles_skipped_push(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'push' ) + ->with( 123 ) + ->andReturn( true ); + + Functions\when( 'get_post_meta' )->justReturn( array() ); + + $result = $this->service->push_to_site( 123, 456 ); + + $this->assertTrue( $result->is_skipped() ); + $this->assertStringContainsString( 'Filtered', $result->message ); + } + + /** + * Test push_to_site handles push error. + */ + public function test_push_to_site_handles_push_error(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + $error = new WP_Error( 'connection_failed', 'Could not connect.' ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'push' ) + ->with( 123 ) + ->andReturn( $error ); + + Functions\when( 'get_post_meta' )->justReturn( array() ); + Functions\when( 'update_post_meta' )->justReturn( true ); + + $result = $this->service->push_to_site( 123, 456 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'connection_failed', $result->error_code ); + $this->assertEquals( 'Could not connect.', $result->message ); + } + + /** + * Test delete_from_site returns failure when no transport. + */ + public function test_delete_from_site_returns_failure_when_no_transport(): void { + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( null ); + + Functions\when( 'get_post_meta' )->justReturn( array() ); + + $result = $this->service->delete_from_site( 123, 456 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'invalid_transport', $result->error_code ); + } + + /** + * Test delete_from_site returns failure when no remote post. + */ + public function test_delete_from_site_returns_failure_when_no_remote_post(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + Functions\when( 'get_post_meta' )->justReturn( array() ); + + $result = $this->service->delete_from_site( 123, 456 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'no_remote_post', $result->error_code ); + } + + /** + * Test delete_from_site succeeds. + */ + public function test_delete_from_site_succeeds(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'delete' ) + ->with( 789 ) + ->andReturn( true ); + + $slave_states = array( + 'success' => array( 456 => 789 ), + ); + + Functions\when( 'get_post_meta' )->justReturn( $slave_states ); + Functions\when( 'update_post_meta' )->justReturn( true ); + + $result = $this->service->delete_from_site( 123, 456 ); + + $this->assertTrue( $result->is_success() ); + $this->assertEquals( 789, $result->remote_id ); + $this->assertEquals( 'deleted', $result->action ); + } + + /** + * Test delete_from_site handles error. + */ + public function test_delete_from_site_handles_error(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + $error = new WP_Error( 'delete_failed', 'Could not delete.' ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + $transport + ->shouldReceive( 'delete' ) + ->with( 789 ) + ->andReturn( $error ); + + $slave_states = array( + 'success' => array( 456 => 789 ), + ); + + Functions\when( 'get_post_meta' )->justReturn( $slave_states ); + Functions\when( 'update_post_meta' )->justReturn( true ); + + $result = $this->service->delete_from_site( 123, 456 ); + + $this->assertTrue( $result->is_failure() ); + $this->assertEquals( 'delete_failed', $result->error_code ); + } + + /** + * Test push_to_site handles retry after new-error state. + */ + public function test_push_to_site_retries_after_new_error(): void { + $transport = Mockery::mock( PushTransportInterface::class ); + $site = Mockery::mock( WP_Post::class ); + + $this->factory + ->shouldReceive( 'create_push_transport' ) + ->with( 456 ) + ->andReturn( $transport ); + + // Previous push failed with new-error state. + $slave_states = array( + 'new-error' => array( 456 => new WP_Error( 'previous_error', 'Previous failure' ) ), + ); + + $transport + ->shouldReceive( 'push' ) + ->with( 123 ) + ->andReturn( 789 ); + + Functions\when( 'get_post_meta' )->justReturn( $slave_states ); + Functions\when( 'update_post_meta' )->justReturn( true ); + Functions\when( 'get_post' )->justReturn( $site ); + Functions\when( 'do_action' )->justReturn( null ); + + $result = $this->service->push_to_site( 123, 456 ); + + // Should retry as new push and succeed. + $this->assertTrue( $result->is_success() ); + $this->assertEquals( 'created', $result->action ); + } +} From 39e1fa97cad928805a9d88d4882ba13c8b918203 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Tue, 13 Jan 2026 14:12:35 +0000 Subject: [PATCH 02/16] feat: add plugin Bootstrapper with DI container integration Create Bootstrapper class as the main entry point for the new DDD architecture. The Bootstrapper: - Initialises the DI container as a singleton - Registers PushService and PullService in the container - Provides hook manager access for WordPress integration - Includes reset() method for testing support Add syn_get_container action hook to provide container access to legacy code during the migration period. All 215 unit tests pass. Co-Authored-By: Claude Opus 4.5 --- includes/Application/Bootstrapper.php | 185 ++++++++++++++++++++ tests/Unit/Application/BootstrapperTest.php | 171 ++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 includes/Application/Bootstrapper.php create mode 100644 tests/Unit/Application/BootstrapperTest.php diff --git a/includes/Application/Bootstrapper.php b/includes/Application/Bootstrapper.php new file mode 100644 index 0000000..19c60c8 --- /dev/null +++ b/includes/Application/Bootstrapper.php @@ -0,0 +1,185 @@ +container = $container; + $this->hooks = $container->get( HookManager::class ); + } + + /** + * Initialise the bootstrapper. + * + * @param Container|null $container Optional container for testing. + * @return self The singleton instance. + */ + public static function init( ?Container $container = null ): self { + if ( null === self::$instance ) { + self::$instance = new self( $container ?? new Container() ); + } + + if ( ! self::$instance->initialised ) { + self::$instance->register_services(); + self::$instance->register_hooks(); + self::$instance->initialised = true; + } + + return self::$instance; + } + + /** + * Get the singleton instance. + * + * @return self|null The instance or null if not initialised. + */ + public static function get_instance(): ?self { + return self::$instance; + } + + /** + * Reset the singleton instance (for testing). + */ + public static function reset(): void { + self::$instance = null; + } + + /** + * Get the DI container. + * + * @return Container The container. + */ + public function container(): Container { + return $this->container; + } + + /** + * Get the hook manager. + * + * @return HookManager The hook manager. + */ + public function hooks(): HookManager { + return $this->hooks; + } + + /** + * Register additional services in the container. + */ + private function register_services(): void { + // Register PushService. + $this->container->register( + PushService::class, + function ( Container $container ): PushService { + $factory = $container->get( TransportFactoryInterface::class ); + \assert( $factory instanceof TransportFactoryInterface ); + return new PushService( $factory ); + } + ); + + // Register PullService. + $this->container->register( + PullService::class, + function ( Container $container ): PullService { + $factory = $container->get( TransportFactoryInterface::class ); + \assert( $factory instanceof TransportFactoryInterface ); + return new PullService( $factory ); + } + ); + } + + /** + * Register WordPress hooks. + * + * Note: These hooks will eventually replace the hooks registered + * in the legacy WP_Push_Syndication_Server class. + */ + private function register_hooks(): void { + // Register hook to provide DI container to legacy code. + $this->hooks->add_action( + 'syn_get_container', + function (): Container { + return $this->container; + } + ); + + // Future: Register cron hooks for scheduled syndication. + // Future: Register admin menu hooks. + // Future: Register REST API endpoints. + } + + /** + * Get the push service. + * + * @return PushService The push service. + */ + public function push_service(): PushService { + $service = $this->container->get( PushService::class ); + \assert( $service instanceof PushService ); + return $service; + } + + /** + * Get the pull service. + * + * @return PullService The pull service. + */ + public function pull_service(): PullService { + $service = $this->container->get( PullService::class ); + \assert( $service instanceof PullService ); + return $service; + } +} diff --git a/tests/Unit/Application/BootstrapperTest.php b/tests/Unit/Application/BootstrapperTest.php new file mode 100644 index 0000000..dfe99bf --- /dev/null +++ b/tests/Unit/Application/BootstrapperTest.php @@ -0,0 +1,171 @@ +once(); + + $bootstrapper = Bootstrapper::init(); + + $this->assertInstanceOf( Bootstrapper::class, $bootstrapper ); + $this->assertSame( $bootstrapper, Bootstrapper::get_instance() ); + } + + /** + * Test init returns same instance on subsequent calls. + */ + public function test_init_returns_same_instance(): void { + Actions\expectAdded( 'syn_get_container' )->once(); + + $first = Bootstrapper::init(); + $second = Bootstrapper::init(); + + $this->assertSame( $first, $second ); + } + + /** + * Test get_instance returns null before init. + */ + public function test_get_instance_returns_null_before_init(): void { + $this->assertNull( Bootstrapper::get_instance() ); + } + + /** + * Test reset clears the singleton. + */ + public function test_reset_clears_singleton(): void { + Actions\expectAdded( 'syn_get_container' )->once(); + + Bootstrapper::init(); + Bootstrapper::reset(); + + $this->assertNull( Bootstrapper::get_instance() ); + } + + /** + * Test container returns DI container. + */ + public function test_container_returns_di_container(): void { + Actions\expectAdded( 'syn_get_container' )->once(); + + $bootstrapper = Bootstrapper::init(); + $container = $bootstrapper->container(); + + $this->assertInstanceOf( Container::class, $container ); + } + + /** + * Test hooks returns hook manager. + */ + public function test_hooks_returns_hook_manager(): void { + Actions\expectAdded( 'syn_get_container' )->once(); + + $bootstrapper = Bootstrapper::init(); + $hooks = $bootstrapper->hooks(); + + $this->assertInstanceOf( HookManager::class, $hooks ); + } + + /** + * Test push_service returns PushService. + */ + public function test_push_service_returns_push_service(): void { + Actions\expectAdded( 'syn_get_container' )->once(); + + $bootstrapper = Bootstrapper::init(); + $service = $bootstrapper->push_service(); + + $this->assertInstanceOf( PushService::class, $service ); + } + + /** + * Test pull_service returns PullService. + */ + public function test_pull_service_returns_pull_service(): void { + Actions\expectAdded( 'syn_get_container' )->once(); + + $bootstrapper = Bootstrapper::init(); + $service = $bootstrapper->pull_service(); + + $this->assertInstanceOf( PullService::class, $service ); + } + + /** + * Test init accepts custom container. + */ + public function test_init_accepts_custom_container(): void { + $container = new Container(); + Actions\expectAdded( 'syn_get_container' )->once(); + + $bootstrapper = Bootstrapper::init( $container ); + + $this->assertSame( $container, $bootstrapper->container() ); + } + + /** + * Test services are registered in container. + */ + public function test_services_are_registered(): void { + Actions\expectAdded( 'syn_get_container' )->once(); + + $bootstrapper = Bootstrapper::init(); + $container = $bootstrapper->container(); + + $this->assertTrue( $container->has( PushService::class ) ); + $this->assertTrue( $container->has( PullService::class ) ); + } + + /** + * Test syn_get_container hook is registered. + */ + public function test_syn_get_container_hook_registered(): void { + Actions\expectAdded( 'syn_get_container' ) + ->once() + ->with( \Mockery::type( 'callable' ), 10, 1 ); + + Bootstrapper::init(); + } +} From 77c1a46a1df4ddac312854821f527795de38a9e9 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Mon, 19 Jan 2026 12:20:55 +0000 Subject: [PATCH 03/16] feat: wire up Bootstrapper and add HookRegistrar for WordPress integration Wire up the new DDD architecture in the main plugin file: - Initialise Bootstrapper after autoloader registration - Add syndication_container() helper function for legacy code access Add HookRegistrar to mirror WP_Push_Syndication_Server hook registration: - Registers WordPress core hooks (init, admin_init, save_post, etc.) - Empty placeholder handlers with @todo comments for implementation - on_cron_schedules implemented (adds syn_pull_time_interval) When ready to migrate: 1. Implement placeholder handlers using new services 2. Remove legacy handlers from WP_Push_Syndication_Server 3. Delete legacy code All 227 unit tests pass. Co-Authored-By: Claude Opus 4.5 --- includes/Application/Bootstrapper.php | 30 ++- includes/Application/HookRegistrar.php | 230 +++++++++++++++++++ push-syndication.php | 17 ++ tests/Unit/Application/HookRegistrarTest.php | 203 ++++++++++++++++ 4 files changed, 473 insertions(+), 7 deletions(-) create mode 100644 includes/Application/HookRegistrar.php create mode 100644 tests/Unit/Application/HookRegistrarTest.php diff --git a/includes/Application/Bootstrapper.php b/includes/Application/Bootstrapper.php index 19c60c8..e40c16d 100644 --- a/includes/Application/Bootstrapper.php +++ b/includes/Application/Bootstrapper.php @@ -45,6 +45,13 @@ final class Bootstrapper { */ private readonly HookManager $hooks; + /** + * Hook registrar. + * + * @var HookRegistrar + */ + private readonly HookRegistrar $hook_registrar; + /** * Whether the bootstrapper has been initialised. * @@ -58,8 +65,9 @@ final class Bootstrapper { * @param Container $container DI container. */ private function __construct( Container $container ) { - $this->container = $container; - $this->hooks = $container->get( HookManager::class ); + $this->container = $container; + $this->hooks = $container->get( HookManager::class ); + $this->hook_registrar = new HookRegistrar( $container, $this->hooks ); } /** @@ -144,8 +152,8 @@ function ( Container $container ): PullService { /** * Register WordPress hooks. * - * Note: These hooks will eventually replace the hooks registered - * in the legacy WP_Push_Syndication_Server class. + * Note: These hooks run in parallel with the legacy hooks during + * migration. Once verified, the legacy code can be removed. */ private function register_hooks(): void { // Register hook to provide DI container to legacy code. @@ -156,9 +164,17 @@ function (): Container { } ); - // Future: Register cron hooks for scheduled syndication. - // Future: Register admin menu hooks. - // Future: Register REST API endpoints. + // Register all hooks via the HookRegistrar. + $this->hook_registrar->register(); + } + + /** + * Get the hook registrar. + * + * @return HookRegistrar The hook registrar. + */ + public function hook_registrar(): HookRegistrar { + return $this->hook_registrar; } /** diff --git a/includes/Application/HookRegistrar.php b/includes/Application/HookRegistrar.php new file mode 100644 index 0000000..96592d5 --- /dev/null +++ b/includes/Application/HookRegistrar.php @@ -0,0 +1,230 @@ +container = $container; + $this->hooks = $hooks; + } + + /** + * Register all hooks. + * + * Call this method to register all WordPress hooks. This mirrors + * the hook registration in WP_Push_Syndication_Server::__construct(). + */ + public function register(): void { + $this->register_initialisation_hooks(); + $this->register_admin_hooks(); + $this->register_syndication_hooks(); + $this->register_cron_hooks(); + $this->register_site_management_hooks(); + } + + /** + * Register initialisation hooks. + * + * @see WP_Push_Syndication_Server::init() + */ + private function register_initialisation_hooks(): void { + // Post type and taxonomy registration will move here from legacy code. + $this->hooks->add_action( 'init', array( $this, 'on_init' ), 10, 0 ); + } + + /** + * Register admin hooks. + * + * @see WP_Push_Syndication_Server::admin_init() + */ + private function register_admin_hooks(): void { + $this->hooks->add_action( 'admin_init', array( $this, 'on_admin_init' ), 10, 0 ); + } + + /** + * Register content syndication hooks. + * + * @see WP_Push_Syndication_Server - transition_post_status, wp_trash_post + */ + private function register_syndication_hooks(): void { + $this->hooks->add_action( 'transition_post_status', array( $this, 'on_transition_post_status' ), 10, 3 ); + $this->hooks->add_action( 'wp_trash_post', array( $this, 'on_trash_post' ), 10, 1 ); + } + + /** + * Register cron-related hooks. + * + * @see WP_Push_Syndication_Server::cron_add_pull_time_interval() + */ + private function register_cron_hooks(): void { + $this->hooks->add_filter( 'cron_schedules', array( $this, 'on_cron_schedules' ), 10, 1 ); + } + + /** + * Register site management hooks. + * + * @see WP_Push_Syndication_Server - save_post, delete_post, create_term, delete_term + */ + private function register_site_management_hooks(): void { + $this->hooks->add_action( 'save_post', array( $this, 'on_save_post' ), 10, 3 ); + $this->hooks->add_action( 'delete_post', array( $this, 'on_delete_post' ), 10, 1 ); + $this->hooks->add_action( 'create_term', array( $this, 'on_create_term' ), 10, 3 ); + $this->hooks->add_action( 'delete_term', array( $this, 'on_delete_term' ), 10, 3 ); + } + + /** + * Handle init action. + * + * @todo Implement post type and taxonomy registration. + */ + public function on_init(): void { + // Will register syn_site post type and syn_sitegroup taxonomy. + } + + /** + * Handle admin_init action. + * + * @todo Implement admin initialisation. + */ + public function on_admin_init(): void { + // Will handle admin setup. + } + + /** + * Handle transition_post_status action. + * + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param \WP_Post $post Post object. + * + * @todo Implement syndication triggering via PushService. + */ + public function on_transition_post_status( string $new_status, string $old_status, \WP_Post $post ): void { + // Will trigger push syndication when post is published. + unset( $new_status, $old_status, $post ); + } + + /** + * Handle wp_trash_post action. + * + * @param int $post_id Post ID being trashed. + * + * @todo Implement remote deletion via PushService. + */ + public function on_trash_post( int $post_id ): void { + // Will trigger deletion from remote sites. + unset( $post_id ); + } + + /** + * Handle cron_schedules filter. + * + * @param array $schedules Existing schedules. + * @return array Modified schedules. + */ + public function on_cron_schedules( array $schedules ): array { + $settings = get_option( 'push_syndicate_settings' ); + $pull_time_interval = $settings['pull_time_interval'] ?? 3600; + + $schedules['syn_pull_time_interval'] = array( + 'interval' => (int) $pull_time_interval, + 'display' => __( 'Pull Time Interval', 'push-syndication' ), + ); + + return $schedules; + } + + /** + * Handle save_post action. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @param bool $update Whether this is an update. + * + * @todo Implement site settings saving. + */ + public function on_save_post( int $post_id, \WP_Post $post, bool $update ): void { + // Will handle syn_site post saves. + unset( $post_id, $post, $update ); + } + + /** + * Handle delete_post action. + * + * @param int $post_id Post ID being deleted. + * + * @todo Implement site deletion handling. + */ + public function on_delete_post( int $post_id ): void { + // Will handle syn_site deletion and scheduled content deletion. + unset( $post_id ); + } + + /** + * Handle create_term action. + * + * @param int $term_id Term ID. + * @param int $tt_id Term taxonomy ID. + * @param string $taxonomy Taxonomy slug. + * + * @todo Implement site group creation handling. + */ + public function on_create_term( int $term_id, int $tt_id, string $taxonomy ): void { + // Will handle syn_sitegroup creation. + unset( $term_id, $tt_id, $taxonomy ); + } + + /** + * Handle delete_term action. + * + * @param int $term_id Term ID. + * @param int $tt_id Term taxonomy ID. + * @param string $taxonomy Taxonomy slug. + * + * @todo Implement site group deletion handling. + */ + public function on_delete_term( int $term_id, int $tt_id, string $taxonomy ): void { + // Will handle syn_sitegroup deletion. + unset( $term_id, $tt_id, $taxonomy ); + } +} diff --git a/push-syndication.php b/push-syndication.php index a4659da..89cf053 100644 --- a/push-syndication.php +++ b/push-syndication.php @@ -26,6 +26,23 @@ define( 'PUSH_SYNDICATE_KEY', 'PUSH_SYNDICATE_KEY' ); } +// Initialise the new DDD architecture. +use Automattic\Syndication\Application\Bootstrapper; + +$GLOBALS['syndication_bootstrapper'] = Bootstrapper::init(); + +/** + * Get the Syndication DI container. + * + * Helper function for accessing the dependency injection container + * from legacy code during the migration to the new architecture. + * + * @return \Automattic\Syndication\Infrastructure\DI\Container The container. + */ +function syndication_container(): \Automattic\Syndication\Infrastructure\DI\Container { + return Bootstrapper::get_instance()->container(); +} + /** * Load syndication logger */ diff --git a/tests/Unit/Application/HookRegistrarTest.php b/tests/Unit/Application/HookRegistrarTest.php new file mode 100644 index 0000000..1175c73 --- /dev/null +++ b/tests/Unit/Application/HookRegistrarTest.php @@ -0,0 +1,203 @@ +container = new Container(); + $this->hooks = new HookManager(); + $this->registrar = new HookRegistrar( $this->container, $this->hooks ); + } + + /** + * Test register adds all required hooks. + */ + public function test_register_adds_all_hooks(): void { + Actions\expectAdded( 'init' )->once(); + Actions\expectAdded( 'admin_init' )->once(); + Actions\expectAdded( 'transition_post_status' )->once(); + Actions\expectAdded( 'wp_trash_post' )->once(); + Filters\expectAdded( 'cron_schedules' )->once(); + Actions\expectAdded( 'save_post' )->once(); + Actions\expectAdded( 'delete_post' )->once(); + Actions\expectAdded( 'create_term' )->once(); + Actions\expectAdded( 'delete_term' )->once(); + + $this->registrar->register(); + } + + /** + * Test on_cron_schedules adds custom interval. + */ + public function test_on_cron_schedules_adds_interval(): void { + Functions\when( 'get_option' )->justReturn( + array( 'pull_time_interval' => 7200 ) + ); + Functions\when( '__' )->returnArg( 1 ); + + $schedules = $this->registrar->on_cron_schedules( array() ); + + $this->assertArrayHasKey( 'syn_pull_time_interval', $schedules ); + $this->assertEquals( 7200, $schedules['syn_pull_time_interval']['interval'] ); + } + + /** + * Test on_cron_schedules uses default interval. + */ + public function test_on_cron_schedules_uses_default_interval(): void { + Functions\when( 'get_option' )->justReturn( array() ); + Functions\when( '__' )->returnArg( 1 ); + + $schedules = $this->registrar->on_cron_schedules( array() ); + + $this->assertEquals( 3600, $schedules['syn_pull_time_interval']['interval'] ); + } + + /** + * Test on_cron_schedules preserves existing schedules. + */ + public function test_on_cron_schedules_preserves_existing(): void { + Functions\when( 'get_option' )->justReturn( array() ); + Functions\when( '__' )->returnArg( 1 ); + + $existing = array( + 'hourly' => array( + 'interval' => 3600, + 'display' => 'Hourly', + ), + ); + $schedules = $this->registrar->on_cron_schedules( $existing ); + + $this->assertArrayHasKey( 'hourly', $schedules ); + $this->assertArrayHasKey( 'syn_pull_time_interval', $schedules ); + } + + /** + * Test on_transition_post_status is callable. + */ + public function test_on_transition_post_status_is_callable(): void { + $post = Mockery::mock( WP_Post::class ); + $post->ID = 123; + + // Should not throw. + $this->registrar->on_transition_post_status( 'publish', 'draft', $post ); + + $this->assertTrue( true ); + } + + /** + * Test on_trash_post is callable. + */ + public function test_on_trash_post_is_callable(): void { + $this->registrar->on_trash_post( 123 ); + + $this->assertTrue( true ); + } + + /** + * Test on_save_post is callable. + */ + public function test_on_save_post_is_callable(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'post'; + + $this->registrar->on_save_post( 123, $post, true ); + + $this->assertTrue( true ); + } + + /** + * Test on_delete_post is callable. + */ + public function test_on_delete_post_is_callable(): void { + $this->registrar->on_delete_post( 123 ); + + $this->assertTrue( true ); + } + + /** + * Test on_create_term is callable. + */ + public function test_on_create_term_is_callable(): void { + $this->registrar->on_create_term( 1, 2, 'syn_sitegroup' ); + + $this->assertTrue( true ); + } + + /** + * Test on_delete_term is callable. + */ + public function test_on_delete_term_is_callable(): void { + $this->registrar->on_delete_term( 1, 2, 'syn_sitegroup' ); + + $this->assertTrue( true ); + } + + /** + * Test on_init is callable. + */ + public function test_on_init_is_callable(): void { + $this->registrar->on_init(); + + $this->assertTrue( true ); + } + + /** + * Test on_admin_init is callable. + */ + public function test_on_admin_init_is_callable(): void { + $this->registrar->on_admin_init(); + + $this->assertTrue( true ); + } +} From dd091176351ef3d8b4bbe40f09d3de0dc2df3826 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Mon, 19 Jan 2026 12:36:54 +0000 Subject: [PATCH 04/16] feat: add PostTypeRegistrar for post type and taxonomy registration Extract post type (syn_site) and taxonomy (syn_sitegroup) registration into a dedicated PostTypeRegistrar class. This encapsulates the registration logic previously in WP_Push_Syndication_Server::init() and makes it available through the DI container. Key changes: - PostTypeRegistrar handles all registration with idempotent checks - HookRegistrar.on_init() now uses PostTypeRegistrar via the container - Supports syn_syndicate_cap filter for custom capability requirements Co-Authored-By: Claude Opus 4.5 --- includes/Application/HookRegistrar.php | 16 +- includes/Infrastructure/DI/Container.php | 8 + .../WordPress/PostTypeRegistrar.php | 170 ++++++++++++++++++ tests/Unit/Application/HookRegistrarTest.php | 29 ++- .../WordPress/PostTypeRegistrarTest.php | 137 ++++++++++++++ 5 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 includes/Infrastructure/WordPress/PostTypeRegistrar.php create mode 100644 tests/Unit/Infrastructure/WordPress/PostTypeRegistrarTest.php diff --git a/includes/Application/HookRegistrar.php b/includes/Application/HookRegistrar.php index 96592d5..de8fc00 100644 --- a/includes/Application/HookRegistrar.php +++ b/includes/Application/HookRegistrar.php @@ -11,6 +11,7 @@ use Automattic\Syndication\Infrastructure\DI\Container; use Automattic\Syndication\Infrastructure\WordPress\HookManager; +use Automattic\Syndication\Infrastructure\WordPress\PostTypeRegistrar; /** * Registers WordPress hooks for the new architecture. @@ -115,10 +116,21 @@ private function register_site_management_hooks(): void { /** * Handle init action. * - * @todo Implement post type and taxonomy registration. + * Registers the syn_site post type and syn_sitegroup taxonomy. + * Safe to call even when legacy code also registers - will skip if already registered. */ public function on_init(): void { - // Will register syn_site post type and syn_sitegroup taxonomy. + $registrar = $this->container->get( PostTypeRegistrar::class ); + \assert( $registrar instanceof PostTypeRegistrar ); + $registrar->register(); + + /** + * Fires after syndication server initialisation. + * + * @since 2.0.0 + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + do_action( 'syn_after_init_server' ); } /** diff --git a/includes/Infrastructure/DI/Container.php b/includes/Infrastructure/DI/Container.php index 1d4cabb..e860cac 100644 --- a/includes/Infrastructure/DI/Container.php +++ b/includes/Infrastructure/DI/Container.php @@ -16,6 +16,7 @@ use Automattic\Syndication\Infrastructure\Repositories\SiteRepository; use Automattic\Syndication\Infrastructure\Transport\TransportFactory; use Automattic\Syndication\Infrastructure\WordPress\HookManager; +use Automattic\Syndication\Infrastructure\WordPress\PostTypeRegistrar; /** * Simple dependency injection container for Syndication services. @@ -154,6 +155,13 @@ static function (): HookManager { } ); + $this->register( + PostTypeRegistrar::class, + static function (): PostTypeRegistrar { + return new PostTypeRegistrar(); + } + ); + // Transport factory - register both interface and concrete class. $factory_callback = function ( Container $container ): TransportFactory { $encryptor = $container->get( EncryptorInterface::class ); diff --git a/includes/Infrastructure/WordPress/PostTypeRegistrar.php b/includes/Infrastructure/WordPress/PostTypeRegistrar.php new file mode 100644 index 0000000..7330699 --- /dev/null +++ b/includes/Infrastructure/WordPress/PostTypeRegistrar.php @@ -0,0 +1,170 @@ +register_post_type(); + $this->register_taxonomy(); + } + + /** + * Register the syn_site post type. + */ + private function register_post_type(): void { + if ( post_type_exists( self::POST_TYPE ) ) { + return; + } + + $capability = $this->get_capability(); + + $capabilities = array( + 'edit_post' => $capability, + 'read_post' => $capability, + 'delete_post' => $capability, + 'delete_posts' => $capability, + 'edit_posts' => $capability, + 'edit_others_posts' => $capability, + 'publish_posts' => $capability, + 'read_private_posts' => $capability, + ); + + register_post_type( + self::POST_TYPE, + array( + 'labels' => $this->get_post_type_labels(), + 'description' => __( 'Sites in the network', 'push-syndication' ), + 'public' => false, + 'show_ui' => true, + 'publicly_queryable' => false, + 'exclude_from_search' => true, + 'menu_position' => 100, + 'hierarchical' => false, + 'query_var' => false, + 'rewrite' => false, + 'supports' => array( 'title' ), + 'can_export' => true, + 'capabilities' => $capabilities, + ) + ); + } + + /** + * Register the syn_sitegroup taxonomy. + */ + private function register_taxonomy(): void { + if ( taxonomy_exists( self::TAXONOMY ) ) { + return; + } + + $capabilities = array( + 'manage_terms' => 'manage_categories', + 'edit_terms' => 'manage_categories', + 'delete_terms' => 'manage_categories', + 'assign_terms' => 'edit_posts', + ); + + register_taxonomy( + self::TAXONOMY, + self::POST_TYPE, + array( + 'labels' => $this->get_taxonomy_labels(), + 'public' => false, + 'show_ui' => true, + 'show_tagcloud' => false, + 'show_in_nav_menus' => false, + 'hierarchical' => true, + 'rewrite' => false, + 'capabilities' => $capabilities, + ) + ); + } + + /** + * Get the capability required for syndication. + * + * @return string The capability. + */ + private function get_capability(): string { + /** + * Filters the capability required for syndication operations. + * + * @param string $capability Default capability. + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + return apply_filters( 'syn_syndicate_cap', self::DEFAULT_CAPABILITY ); + } + + /** + * Get post type labels. + * + * @return array Labels array. + */ + private function get_post_type_labels(): array { + return array( + 'name' => __( 'Sites', 'push-syndication' ), + 'singular_name' => __( 'Site', 'push-syndication' ), + 'add_new' => __( 'Add Site', 'push-syndication' ), + 'add_new_item' => __( 'Add New Site', 'push-syndication' ), + 'edit_item' => __( 'Edit Site', 'push-syndication' ), + 'new_item' => __( 'New Site', 'push-syndication' ), + 'view_item' => __( 'View Site', 'push-syndication' ), + 'search_items' => __( 'Search Sites', 'push-syndication' ), + ); + } + + /** + * Get taxonomy labels. + * + * @return array Labels array. + */ + private function get_taxonomy_labels(): array { + return array( + 'name' => __( 'Site Groups', 'push-syndication' ), + 'singular_name' => __( 'Site Group', 'push-syndication' ), + 'search_items' => __( 'Search Site Groups', 'push-syndication' ), + 'popular_items' => __( 'Popular Site Groups', 'push-syndication' ), + 'all_items' => __( 'All Site Groups', 'push-syndication' ), + 'parent_item' => __( 'Parent Site Group', 'push-syndication' ), + 'parent_item_colon' => __( 'Parent Site Group', 'push-syndication' ), + 'edit_item' => __( 'Edit Site Group', 'push-syndication' ), + 'update_item' => __( 'Update Site Group', 'push-syndication' ), + 'add_new_item' => __( 'Add New Site Group', 'push-syndication' ), + 'new_item_name' => __( 'New Site Group Name', 'push-syndication' ), + ); + } +} diff --git a/tests/Unit/Application/HookRegistrarTest.php b/tests/Unit/Application/HookRegistrarTest.php index 1175c73..63924c3 100644 --- a/tests/Unit/Application/HookRegistrarTest.php +++ b/tests/Unit/Application/HookRegistrarTest.php @@ -184,12 +184,35 @@ public function test_on_delete_term_is_callable(): void { } /** - * Test on_init is callable. + * Test on_init registers post type and taxonomy. */ - public function test_on_init_is_callable(): void { + public function test_on_init_registers_post_type_and_taxonomy(): void { + Functions\when( 'post_type_exists' )->justReturn( false ); + Functions\when( 'taxonomy_exists' )->justReturn( false ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( '__' )->returnArg( 1 ); + Functions\expect( 'register_post_type' ) + ->once() + ->with( 'syn_site', Mockery::type( 'array' ) ); + Functions\expect( 'register_taxonomy' ) + ->once() + ->with( 'syn_sitegroup', 'syn_site', Mockery::type( 'array' ) ); + Actions\expectDone( 'syn_after_init_server' )->once(); + $this->registrar->on_init(); + } - $this->assertTrue( true ); + /** + * Test on_init skips registration if already registered. + */ + public function test_on_init_skips_if_already_registered(): void { + Functions\when( 'post_type_exists' )->justReturn( true ); + Functions\when( 'taxonomy_exists' )->justReturn( true ); + Functions\expect( 'register_post_type' )->never(); + Functions\expect( 'register_taxonomy' )->never(); + Actions\expectDone( 'syn_after_init_server' )->once(); + + $this->registrar->on_init(); } /** diff --git a/tests/Unit/Infrastructure/WordPress/PostTypeRegistrarTest.php b/tests/Unit/Infrastructure/WordPress/PostTypeRegistrarTest.php new file mode 100644 index 0000000..eace23e --- /dev/null +++ b/tests/Unit/Infrastructure/WordPress/PostTypeRegistrarTest.php @@ -0,0 +1,137 @@ +registrar = new PostTypeRegistrar(); + } + + /** + * Test register calls both post type and taxonomy registration. + */ + public function test_register_registers_post_type_and_taxonomy(): void { + Functions\when( 'post_type_exists' )->justReturn( false ); + Functions\when( 'taxonomy_exists' )->justReturn( false ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( '__' )->returnArg( 1 ); + Functions\expect( 'register_post_type' ) + ->once() + ->with( 'syn_site', Mockery::type( 'array' ) ); + Functions\expect( 'register_taxonomy' ) + ->once() + ->with( 'syn_sitegroup', 'syn_site', Mockery::type( 'array' ) ); + + $this->registrar->register(); + } + + /** + * Test register skips post type if already exists. + */ + public function test_register_skips_post_type_if_exists(): void { + Functions\when( 'post_type_exists' )->justReturn( true ); + Functions\when( 'taxonomy_exists' )->justReturn( false ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( '__' )->returnArg( 1 ); + Functions\expect( 'register_post_type' )->never(); + Functions\expect( 'register_taxonomy' ) + ->once() + ->with( 'syn_sitegroup', 'syn_site', Mockery::type( 'array' ) ); + + $this->registrar->register(); + } + + /** + * Test register skips taxonomy if already exists. + */ + public function test_register_skips_taxonomy_if_exists(): void { + Functions\when( 'post_type_exists' )->justReturn( false ); + Functions\when( 'taxonomy_exists' )->justReturn( true ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( '__' )->returnArg( 1 ); + Functions\expect( 'register_post_type' ) + ->once() + ->with( 'syn_site', Mockery::type( 'array' ) ); + Functions\expect( 'register_taxonomy' )->never(); + + $this->registrar->register(); + } + + /** + * Test register skips both if already registered. + */ + public function test_register_skips_both_if_exist(): void { + Functions\when( 'post_type_exists' )->justReturn( true ); + Functions\when( 'taxonomy_exists' )->justReturn( true ); + Functions\expect( 'register_post_type' )->never(); + Functions\expect( 'register_taxonomy' )->never(); + + $this->registrar->register(); + } + + /** + * Test post type uses filtered capability. + */ + public function test_register_uses_filtered_capability(): void { + Functions\when( 'post_type_exists' )->justReturn( false ); + Functions\when( 'taxonomy_exists' )->justReturn( true ); + Functions\when( '__' )->returnArg( 1 ); + Functions\expect( 'apply_filters' ) + ->once() + ->with( 'syn_syndicate_cap', 'manage_options' ) + ->andReturn( 'custom_cap' ); + + Functions\expect( 'register_post_type' ) + ->once() + ->with( + 'syn_site', + Mockery::on( + function ( $args ) { + return $args['capabilities']['edit_post'] === 'custom_cap'; + } + ) + ); + + $this->registrar->register(); + } + + /** + * Test constants are defined. + */ + public function test_constants_are_defined(): void { + $this->assertSame( 'syn_site', PostTypeRegistrar::POST_TYPE ); + $this->assertSame( 'syn_sitegroup', PostTypeRegistrar::TAXONOMY ); + $this->assertSame( 'manage_options', PostTypeRegistrar::DEFAULT_CAPABILITY ); + } +} From 9070124d4d4e806fc4f1879086266caa7e8edd4b Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Mon, 19 Jan 2026 12:48:22 +0000 Subject: [PATCH 05/16] test: add unit and integration tests for new architecture Add comprehensive tests for the DDD architecture components: Unit tests: - ContainerTest: Tests DI container registration, resolution, caching, and get_services_by_interface functionality Integration tests: - BootstrapperIntegrationTest: Verifies plugin bootstrap, container availability, services, and global helper function - PostTypeRegistrationTest: Confirms syn_site post type and syn_sitegroup taxonomy are registered with correct properties - HookRegistrationTest: Validates all WordPress hooks are registered and functioning correctly Test counts: 250 unit tests, 31 new integration tests Co-Authored-By: Claude Opus 4.5 --- .../BootstrapperIntegrationTest.php | 132 +++++++++ .../Application/HookRegistrationTest.php | 149 ++++++++++ .../Application/PostTypeRegistrationTest.php | 133 +++++++++ .../Unit/Infrastructure/DI/ContainerTest.php | 275 ++++++++++++++++++ 4 files changed, 689 insertions(+) create mode 100644 tests/Integration/Application/BootstrapperIntegrationTest.php create mode 100644 tests/Integration/Application/HookRegistrationTest.php create mode 100644 tests/Integration/Application/PostTypeRegistrationTest.php create mode 100644 tests/Unit/Infrastructure/DI/ContainerTest.php diff --git a/tests/Integration/Application/BootstrapperIntegrationTest.php b/tests/Integration/Application/BootstrapperIntegrationTest.php new file mode 100644 index 0000000..5f2adb9 --- /dev/null +++ b/tests/Integration/Application/BootstrapperIntegrationTest.php @@ -0,0 +1,132 @@ +assertInstanceOf( Bootstrapper::class, $bootstrapper ); + } + + /** + * Test container is available. + */ + public function test_container_is_available(): void { + $bootstrapper = Bootstrapper::get_instance(); + + $this->assertInstanceOf( Container::class, $bootstrapper->container() ); + } + + /** + * Test hook manager is available. + */ + public function test_hook_manager_is_available(): void { + $bootstrapper = Bootstrapper::get_instance(); + + $this->assertInstanceOf( HookManager::class, $bootstrapper->hooks() ); + } + + /** + * Test hook registrar is available. + */ + public function test_hook_registrar_is_available(): void { + $bootstrapper = Bootstrapper::get_instance(); + + $this->assertInstanceOf( HookRegistrar::class, $bootstrapper->hook_registrar() ); + } + + /** + * Test push service is available. + */ + public function test_push_service_is_available(): void { + $bootstrapper = Bootstrapper::get_instance(); + + $this->assertInstanceOf( PushService::class, $bootstrapper->push_service() ); + } + + /** + * Test pull service is available. + */ + public function test_pull_service_is_available(): void { + $bootstrapper = Bootstrapper::get_instance(); + + $this->assertInstanceOf( PullService::class, $bootstrapper->pull_service() ); + } + + /** + * Test syndication_container helper function exists. + */ + public function test_syndication_container_helper_exists(): void { + $this->assertTrue( function_exists( 'syndication_container' ) ); + } + + /** + * Test syndication_container returns container. + */ + public function test_syndication_container_returns_container(): void { + $container = syndication_container(); + + $this->assertInstanceOf( Container::class, $container ); + } + + /** + * Test syndication_container returns same container as bootstrapper. + */ + public function test_syndication_container_returns_bootstrapper_container(): void { + $bootstrapper = Bootstrapper::get_instance(); + + $this->assertSame( + $bootstrapper->container(), + syndication_container() + ); + } + + /** + * Test global bootstrapper variable is set. + */ + public function test_global_bootstrapper_variable_is_set(): void { + $this->assertArrayHasKey( 'syndication_bootstrapper', $GLOBALS ); + $this->assertInstanceOf( Bootstrapper::class, $GLOBALS['syndication_bootstrapper'] ); + } + + /** + * Test container has required services registered. + */ + public function test_container_has_required_services(): void { + $container = syndication_container(); + + $this->assertTrue( $container->has( PushService::class ) ); + $this->assertTrue( $container->has( PullService::class ) ); + $this->assertTrue( $container->has( HookManager::class ) ); + $this->assertTrue( $container->has( PostTypeRegistrar::class ) ); + } +} diff --git a/tests/Integration/Application/HookRegistrationTest.php b/tests/Integration/Application/HookRegistrationTest.php new file mode 100644 index 0000000..66a9d18 --- /dev/null +++ b/tests/Integration/Application/HookRegistrationTest.php @@ -0,0 +1,149 @@ +registrar = Bootstrapper::get_instance()->hook_registrar(); + } + + /** + * Test init hook is registered. + */ + public function test_init_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'init', array( $this->registrar, 'on_init' ) ) + ); + } + + /** + * Test admin_init hook is registered. + */ + public function test_admin_init_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'admin_init', array( $this->registrar, 'on_admin_init' ) ) + ); + } + + /** + * Test transition_post_status hook is registered. + */ + public function test_transition_post_status_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'transition_post_status', array( $this->registrar, 'on_transition_post_status' ) ) + ); + } + + /** + * Test wp_trash_post hook is registered. + */ + public function test_wp_trash_post_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'wp_trash_post', array( $this->registrar, 'on_trash_post' ) ) + ); + } + + /** + * Test cron_schedules filter is registered. + */ + public function test_cron_schedules_filter_is_registered(): void { + $this->assertIsInt( + has_filter( 'cron_schedules', array( $this->registrar, 'on_cron_schedules' ) ) + ); + } + + /** + * Test save_post hook is registered. + */ + public function test_save_post_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'save_post', array( $this->registrar, 'on_save_post' ) ) + ); + } + + /** + * Test delete_post hook is registered. + */ + public function test_delete_post_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'delete_post', array( $this->registrar, 'on_delete_post' ) ) + ); + } + + /** + * Test create_term hook is registered. + */ + public function test_create_term_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'create_term', array( $this->registrar, 'on_create_term' ) ) + ); + } + + /** + * Test delete_term hook is registered. + */ + public function test_delete_term_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'delete_term', array( $this->registrar, 'on_delete_term' ) ) + ); + } + + /** + * Test syn_get_container hook is registered. + */ + public function test_syn_get_container_hook_is_registered(): void { + $this->assertNotFalse( has_action( 'syn_get_container' ) ); + } + + /** + * Test cron_schedules filter adds syn_pull_time_interval. + */ + public function test_cron_schedules_adds_pull_interval(): void { + $schedules = wp_get_schedules(); + + $this->assertArrayHasKey( 'syn_pull_time_interval', $schedules ); + $this->assertArrayHasKey( 'interval', $schedules['syn_pull_time_interval'] ); + $this->assertArrayHasKey( 'display', $schedules['syn_pull_time_interval'] ); + } + + /** + * Test syn_after_init_server action fires. + */ + public function test_syn_after_init_server_fires(): void { + // This action should have already fired during plugin load. + // We can verify by checking it was fired at least once. + $this->assertGreaterThan( 0, did_action( 'syn_after_init_server' ) ); + } +} diff --git a/tests/Integration/Application/PostTypeRegistrationTest.php b/tests/Integration/Application/PostTypeRegistrationTest.php new file mode 100644 index 0000000..187d1ef --- /dev/null +++ b/tests/Integration/Application/PostTypeRegistrationTest.php @@ -0,0 +1,133 @@ +assertTrue( post_type_exists( PostTypeRegistrar::POST_TYPE ) ); + } + + /** + * Test syn_sitegroup taxonomy is registered. + */ + public function test_syn_sitegroup_taxonomy_is_registered(): void { + $this->assertTrue( taxonomy_exists( PostTypeRegistrar::TAXONOMY ) ); + } + + /** + * Test syn_sitegroup taxonomy is attached to syn_site post type. + */ + public function test_taxonomy_is_attached_to_post_type(): void { + $taxonomies = get_object_taxonomies( PostTypeRegistrar::POST_TYPE ); + + $this->assertContains( PostTypeRegistrar::TAXONOMY, $taxonomies ); + } + + /** + * Test syn_site post type has correct properties. + */ + public function test_post_type_properties(): void { + $post_type = get_post_type_object( PostTypeRegistrar::POST_TYPE ); + + $this->assertNotNull( $post_type ); + $this->assertFalse( $post_type->public ); + $this->assertTrue( $post_type->show_ui ); + $this->assertFalse( $post_type->publicly_queryable ); + $this->assertTrue( $post_type->exclude_from_search ); + } + + /** + * Test syn_sitegroup taxonomy has correct properties. + */ + public function test_taxonomy_properties(): void { + $taxonomy = get_taxonomy( PostTypeRegistrar::TAXONOMY ); + + $this->assertNotNull( $taxonomy ); + $this->assertFalse( $taxonomy->public ); + $this->assertTrue( $taxonomy->show_ui ); + $this->assertTrue( $taxonomy->hierarchical ); + } + + /** + * Test can create syn_site post. + */ + public function test_can_create_syn_site_post(): void { + $post_id = self::factory()->post->create( + array( + 'post_type' => PostTypeRegistrar::POST_TYPE, + 'post_title' => 'Test Site', + ) + ); + + $this->assertIsInt( $post_id ); + $this->assertGreaterThan( 0, $post_id ); + + $post = get_post( $post_id ); + $this->assertSame( PostTypeRegistrar::POST_TYPE, $post->post_type ); + $this->assertSame( 'Test Site', $post->post_title ); + } + + /** + * Test can create syn_sitegroup term. + */ + public function test_can_create_syn_sitegroup_term(): void { + $term = self::factory()->term->create_and_get( + array( + 'taxonomy' => PostTypeRegistrar::TAXONOMY, + 'name' => 'Test Site Group', + ) + ); + + $this->assertInstanceOf( \WP_Term::class, $term ); + $this->assertSame( PostTypeRegistrar::TAXONOMY, $term->taxonomy ); + $this->assertSame( 'Test Site Group', $term->name ); + } + + /** + * Test can assign syn_sitegroup to syn_site. + */ + public function test_can_assign_sitegroup_to_site(): void { + $post_id = self::factory()->post->create( + array( + 'post_type' => PostTypeRegistrar::POST_TYPE, + 'post_title' => 'Test Site', + ) + ); + + $term = self::factory()->term->create_and_get( + array( + 'taxonomy' => PostTypeRegistrar::TAXONOMY, + 'name' => 'Test Site Group', + ) + ); + + wp_set_object_terms( $post_id, $term->term_id, PostTypeRegistrar::TAXONOMY ); + + $terms = wp_get_object_terms( $post_id, PostTypeRegistrar::TAXONOMY ); + + $this->assertCount( 1, $terms ); + $this->assertSame( $term->term_id, $terms[0]->term_id ); + } +} diff --git a/tests/Unit/Infrastructure/DI/ContainerTest.php b/tests/Unit/Infrastructure/DI/ContainerTest.php new file mode 100644 index 0000000..4b70e5a --- /dev/null +++ b/tests/Unit/Infrastructure/DI/ContainerTest.php @@ -0,0 +1,275 @@ +container = new Container(); + } + + /** + * Test register adds service. + */ + public function test_register_adds_service(): void { + $this->container->register( + 'test_service', + static function (): \stdClass { + return new \stdClass(); + } + ); + + $this->assertTrue( $this->container->has( 'test_service' ) ); + } + + /** + * Test get returns service instance. + */ + public function test_get_returns_service(): void { + $this->container->register( + 'test_service', + static function (): \stdClass { + $obj = new \stdClass(); + $obj->name = 'test'; + return $obj; + } + ); + + $service = $this->container->get( 'test_service' ); + + $this->assertInstanceOf( \stdClass::class, $service ); + $this->assertSame( 'test', $service->name ); + } + + /** + * Test get returns same instance on subsequent calls. + */ + public function test_get_returns_same_instance(): void { + $this->container->register( + 'test_service', + static function (): \stdClass { + return new \stdClass(); + } + ); + + $first = $this->container->get( 'test_service' ); + $second = $this->container->get( 'test_service' ); + + $this->assertSame( $first, $second ); + } + + /** + * Test get throws for unregistered service. + */ + public function test_get_throws_for_unregistered(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( "Service 'unknown_service' is not registered." ); + + $this->container->get( 'unknown_service' ); + } + + /** + * Test has returns false for unregistered service. + */ + public function test_has_returns_false_for_unregistered(): void { + $this->assertFalse( $this->container->has( 'unknown_service' ) ); + } + + /** + * Test factory receives container. + */ + public function test_factory_receives_container(): void { + $received_container = null; + + $this->container->register( + 'test_service', + static function ( Container $container ) use ( &$received_container ): \stdClass { + $received_container = $container; + return new \stdClass(); + } + ); + + $this->container->get( 'test_service' ); + + $this->assertSame( $this->container, $received_container ); + } + + /** + * Test get_services_by_interface returns matching services. + */ + public function test_get_services_by_interface_returns_matching(): void { + $this->container->register( + 'matching1', + static function (): \ArrayObject { + return new \ArrayObject(); + } + ); + $this->container->register( + 'matching2', + static function (): \ArrayIterator { + return new \ArrayIterator(); + } + ); + $this->container->register( + 'not_matching', + static function (): \stdClass { + return new \stdClass(); + } + ); + + $services = $this->container->get_services_by_interface( \Traversable::class ); + + $this->assertCount( 2, $services ); + $this->assertArrayHasKey( 'matching1', $services ); + $this->assertArrayHasKey( 'matching2', $services ); + } + + /** + * Test get_services_by_interface returns empty array when none match. + */ + public function test_get_services_by_interface_returns_empty_when_none_match(): void { + $this->container->register( + 'service', + static function (): \stdClass { + return new \stdClass(); + } + ); + + $services = $this->container->get_services_by_interface( \Traversable::class ); + + $this->assertEmpty( $services ); + } + + /** + * Test default services are registered. + */ + public function test_default_services_are_registered(): void { + $this->assertTrue( $this->container->has( EncryptorInterface::class ) ); + $this->assertTrue( $this->container->has( SiteRepositoryInterface::class ) ); + $this->assertTrue( $this->container->has( HookManager::class ) ); + $this->assertTrue( $this->container->has( TransportFactory::class ) ); + $this->assertTrue( $this->container->has( TransportFactoryInterface::class ) ); + $this->assertTrue( $this->container->has( PostTypeRegistrar::class ) ); + } + + /** + * Test EncryptorInterface resolves to OpenSSLEncryptor. + */ + public function test_encryptor_resolves_correctly(): void { + $encryptor = $this->container->get( EncryptorInterface::class ); + + $this->assertInstanceOf( OpenSSLEncryptor::class, $encryptor ); + } + + /** + * Test SiteRepositoryInterface resolves to SiteRepository. + */ + public function test_site_repository_resolves_correctly(): void { + $repository = $this->container->get( SiteRepositoryInterface::class ); + + $this->assertInstanceOf( SiteRepository::class, $repository ); + } + + /** + * Test HookManager resolves correctly. + */ + public function test_hook_manager_resolves_correctly(): void { + $hooks = $this->container->get( HookManager::class ); + + $this->assertInstanceOf( HookManager::class, $hooks ); + } + + /** + * Test TransportFactory resolves correctly. + */ + public function test_transport_factory_resolves_correctly(): void { + $factory = $this->container->get( TransportFactory::class ); + + $this->assertInstanceOf( TransportFactory::class, $factory ); + } + + /** + * Test TransportFactoryInterface resolves to TransportFactory. + */ + public function test_transport_factory_interface_resolves_to_transport_factory(): void { + $factory = $this->container->get( TransportFactoryInterface::class ); + + $this->assertInstanceOf( TransportFactory::class, $factory ); + } + + /** + * Test PostTypeRegistrar resolves correctly. + */ + public function test_post_type_registrar_resolves_correctly(): void { + $registrar = $this->container->get( PostTypeRegistrar::class ); + + $this->assertInstanceOf( PostTypeRegistrar::class, $registrar ); + } + + /** + * Test register overwrites existing service. + */ + public function test_register_overwrites_existing(): void { + $this->container->register( + 'test_service', + static function (): \stdClass { + $obj = new \stdClass(); + $obj->name = 'original'; + return $obj; + } + ); + + // Get the original to cache it. + $original = $this->container->get( 'test_service' ); + $this->assertSame( 'original', $original->name ); + + // Register new factory - this won't affect cached instance. + $this->container->register( + 'test_service', + static function (): \stdClass { + $obj = new \stdClass(); + $obj->name = 'overwritten'; + return $obj; + } + ); + + // Still returns cached instance. + $this->assertSame( 'original', $this->container->get( 'test_service' )->name ); + } +} From ee7801f3b479610223e82c5bb9cd847da3c1493f Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Mon, 19 Jan 2026 13:10:42 +0000 Subject: [PATCH 06/16] feat: implement HookRegistrar handlers for syndication operations Wire up the HookRegistrar handlers to use the new DDD services: - on_transition_post_status: Saves selected site groups and schedules push content via the syn_schedule_push_content action - on_trash_post: Deletes syndicated content from remote sites using PushService when delete_pushed_posts setting is enabled - on_save_post: Triggers pull job refresh when syn_site posts change - on_delete_post: Triggers pull job refresh when syn_site posts are deleted - on_create_term/on_delete_term: Trigger pull job refresh when syn_sitegroup terms change Includes debounced scheduling for pull job refresh to prevent timeout issues when many sites are configured. Co-Authored-By: Claude Opus 4.5 --- includes/Application/HookRegistrar.php | 295 +++++++++++++++++-- tests/Unit/Application/HookRegistrarTest.php | 116 +++++++- 2 files changed, 374 insertions(+), 37 deletions(-) diff --git a/includes/Application/HookRegistrar.php b/includes/Application/HookRegistrar.php index de8fc00..65ce75d 100644 --- a/includes/Application/HookRegistrar.php +++ b/includes/Application/HookRegistrar.php @@ -9,6 +9,8 @@ namespace Automattic\Syndication\Application; +use Automattic\Syndication\Application\Services\PushService; +use Automattic\Syndication\Domain\Contracts\SiteRepositoryInterface; use Automattic\Syndication\Infrastructure\DI\Container; use Automattic\Syndication\Infrastructure\WordPress\HookManager; use Automattic\Syndication\Infrastructure\WordPress\PostTypeRegistrar; @@ -145,27 +147,233 @@ public function on_admin_init(): void { /** * Handle transition_post_status action. * + * Saves syndication settings and schedules push content when a post + * status changes. Mirrors legacy save_syndicate_settings() and + * pre_schedule_push_content(). + * * @param string $new_status New post status. * @param string $old_status Old post status. * @param \WP_Post $post Post object. - * - * @todo Implement syndication triggering via PushService. */ public function on_transition_post_status( string $new_status, string $old_status, \WP_Post $post ): void { - // Will trigger push syndication when post is published. - unset( $new_status, $old_status, $post ); + unset( $new_status, $old_status ); + + // Skip autosaves. + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + // Skip if no nonce or invalid nonce (form not submitted). + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification happens here. + if ( ! isset( $_POST['syndicate_noncename'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value used only for verification. + if ( ! wp_verify_nonce( $_POST['syndicate_noncename'], 'syndicate_nonce' ) ) { + return; + } + + // Check capability. + if ( ! $this->current_user_can_syndicate() ) { + return; + } + + // Save selected site groups. + $this->save_syndicate_settings( $post->ID ); + + // Get sites for syndication. + $sites = $this->get_sites_by_post_id( $post->ID ); + + if ( empty( $sites['selected_sites'] ) && empty( $sites['removed_sites'] ) ) { + return; + } + + // Schedule push content via action (allows legacy code to hook in). + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + do_action( 'syn_schedule_push_content', $post->ID, $sites ); + } + + /** + * Save syndicate settings for a post. + * + * @param int $post_id The post ID. + */ + private function save_syndicate_settings( int $post_id ): void { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in caller. + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Array values sanitized with sanitize_key. + $selected_sitegroups = ! empty( $_POST['selected_sitegroups'] ) + ? array_map( 'sanitize_key', (array) $_POST['selected_sitegroups'] ) + : array(); + // phpcs:enable + + update_post_meta( $post_id, '_syn_selected_sitegroups', $selected_sitegroups ); + + // Generate unique post ID if not exists (prevents syndication loops). + if ( '' === get_post_meta( $post_id, 'post_uniqueid', true ) ) { + update_post_meta( $post_id, 'post_uniqueid', uniqid() ); + } + } + + /** + * Get sites for syndication by post ID. + * + * Returns selected sites (to push to) and removed sites (to delete from). + * + * @param int $post_id The post ID. + * @return array{post_ID: int, selected_sites: array, removed_sites: array} Sites data. + */ + private function get_sites_by_post_id( int $post_id ): array { + $selected_sitegroups = get_post_meta( $post_id, '_syn_selected_sitegroups', true ); + $selected_sitegroups = is_array( $selected_sitegroups ) ? $selected_sitegroups : array(); + + $old_sitegroups = get_post_meta( $post_id, '_syn_old_sitegroups', true ); + $old_sitegroups = is_array( $old_sitegroups ) ? $old_sitegroups : array(); + + $removed_sitegroups = array_diff( $old_sitegroups, $selected_sitegroups ); + + $data = array( + 'post_ID' => $post_id, + 'selected_sites' => array(), + 'removed_sites' => array(), + ); + + $repository = $this->container->get( SiteRepositoryInterface::class ); + \assert( $repository instanceof SiteRepositoryInterface ); + + // Get selected sites. + foreach ( $selected_sitegroups as $sitegroup ) { + $term = get_term_by( 'slug', $sitegroup, 'syn_sitegroup' ); + if ( ! $term instanceof \WP_Term ) { + continue; + } + + $configs = $repository->get_by_group( $term->term_id ); + foreach ( $configs as $config ) { + if ( $config->is_enabled() ) { + $data['selected_sites'][] = $config->get_site_id(); + } + } + } + + // Get removed sites. + foreach ( $removed_sitegroups as $sitegroup ) { + $term = get_term_by( 'slug', $sitegroup, 'syn_sitegroup' ); + if ( ! $term instanceof \WP_Term ) { + continue; + } + + $configs = $repository->get_by_group( $term->term_id ); + foreach ( $configs as $config ) { + if ( $config->is_enabled() ) { + $data['removed_sites'][] = $config->get_site_id(); + } + } + } + + // Update old sitegroups for next comparison. + update_post_meta( $post_id, '_syn_old_sitegroups', $selected_sitegroups ); + + return $data; + } + + /** + * Check if the current user can syndicate. + * + * @return bool True if user can syndicate. + */ + private function current_user_can_syndicate(): bool { + /** + * Filters the capability required for syndication. + * + * @param string $capability Default capability. + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $capability = apply_filters( 'syn_syndicate_cap', 'manage_options' ); + + return current_user_can( $capability ); } /** * Handle wp_trash_post action. * - * @param int $post_id Post ID being trashed. + * Deletes syndicated content from remote sites when a post is trashed. + * Mirrors legacy delete_content(). * - * @todo Implement remote deletion via PushService. + * @param int $post_id Post ID being trashed. */ public function on_trash_post( int $post_id ): void { - // Will trigger deletion from remote sites. - unset( $post_id ); + // Check if delete on trash is enabled. + $settings = get_option( 'push_syndicate_settings' ); + if ( empty( $settings['delete_pushed_posts'] ) ) { + return; + } + + // Get slave posts (remote posts that were syndicated). + $slave_posts = $this->get_slave_posts( $post_id ); + if ( empty( $slave_posts ) ) { + return; + } + + $push_service = $this->container->get( PushService::class ); + \assert( $push_service instanceof PushService ); + + $delete_errors = get_option( 'syn_delete_error_sites', array() ); + + foreach ( $slave_posts as $site_id => $remote_id ) { + // Check if site is enabled. + if ( 'on' !== get_post_meta( $site_id, 'syn_site_enabled', true ) ) { + continue; + } + + $result = $push_service->delete_from_site( $post_id, $site_id ); + + if ( ! $result->is_success() ) { + $delete_errors[ $site_id ] = array( $remote_id ); + } + + /** + * Fires after a post is deleted from a remote site. + * + * @param bool $success Whether deletion was successful. + * @param int $remote_id Remote post ID. + * @param int $post_id Local post ID. + * @param int $site_id Site post ID. + * @param string $transport_type Transport type. + * @param object $client Transport client (null for new architecture). + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + do_action( 'syn_post_push_delete_post', $result->is_success(), $remote_id, $post_id, $site_id, '', null ); + } + + update_option( 'syn_delete_error_sites', $delete_errors ); + } + + /** + * Get slave posts (remote posts that were syndicated). + * + * @param int $post_id The local post ID. + * @return array Array of site_id => remote_id pairs. + */ + private function get_slave_posts( int $post_id ): array { + $slave_post_states = get_post_meta( $post_id, '_syn_slave_post_states', true ); + if ( empty( $slave_post_states ) || ! is_array( $slave_post_states ) ) { + return array(); + } + + $slave_posts = array(); + + // The state structure is: $state_name => array( $site_id => $info ). + // We only care about successful syncs which have ext_ID. + if ( ! empty( $slave_post_states['success'] ) && is_array( $slave_post_states['success'] ) ) { + foreach ( $slave_post_states['success'] as $site_id => $remote_id ) { + if ( is_numeric( $remote_id ) && $remote_id > 0 ) { + $slave_posts[ (int) $site_id ] = (int) $remote_id; + } + } + } + + return $slave_posts; } /** @@ -189,54 +397,93 @@ public function on_cron_schedules( array $schedules ): array { /** * Handle save_post action. * + * Handles syn_site post saves and triggers pull job refresh. + * Mirrors legacy save_site_settings() and handle_site_change(). + * * @param int $post_id Post ID. * @param \WP_Post $post Post object. * @param bool $update Whether this is an update. - * - * @todo Implement site settings saving. */ public function on_save_post( int $post_id, \WP_Post $post, bool $update ): void { - // Will handle syn_site post saves. - unset( $post_id, $post, $update ); + unset( $update ); + + // Handle site changes (for pull job refresh). + if ( PostTypeRegistrar::POST_TYPE === $post->post_type ) { + $this->schedule_deferred_pull_jobs_refresh(); + } } /** * Handle delete_post action. * - * @param int $post_id Post ID being deleted. + * Triggers pull job refresh when a syn_site is deleted. + * Mirrors legacy handle_site_change(). * - * @todo Implement site deletion handling. + * @param int $post_id Post ID being deleted. */ public function on_delete_post( int $post_id ): void { - // Will handle syn_site deletion and scheduled content deletion. - unset( $post_id ); + $post = get_post( $post_id ); + if ( $post instanceof \WP_Post && PostTypeRegistrar::POST_TYPE === $post->post_type ) { + $this->schedule_deferred_pull_jobs_refresh(); + } } /** * Handle create_term action. * + * Triggers pull job refresh when a syn_sitegroup is created. + * Mirrors legacy handle_site_group_change(). + * * @param int $term_id Term ID. * @param int $tt_id Term taxonomy ID. * @param string $taxonomy Taxonomy slug. - * - * @todo Implement site group creation handling. */ public function on_create_term( int $term_id, int $tt_id, string $taxonomy ): void { - // Will handle syn_sitegroup creation. - unset( $term_id, $tt_id, $taxonomy ); + unset( $term_id, $tt_id ); + + if ( PostTypeRegistrar::TAXONOMY === $taxonomy ) { + $this->schedule_deferred_pull_jobs_refresh(); + } } /** * Handle delete_term action. * + * Triggers pull job refresh when a syn_sitegroup is deleted. + * Mirrors legacy handle_site_group_change(). + * * @param int $term_id Term ID. * @param int $tt_id Term taxonomy ID. * @param string $taxonomy Taxonomy slug. - * - * @todo Implement site group deletion handling. */ public function on_delete_term( int $term_id, int $tt_id, string $taxonomy ): void { - // Will handle syn_sitegroup deletion. - unset( $term_id, $tt_id, $taxonomy ); + unset( $term_id, $tt_id ); + + if ( PostTypeRegistrar::TAXONOMY === $taxonomy ) { + $this->schedule_deferred_pull_jobs_refresh(); + } + } + + /** + * Schedule a deferred refresh of pull jobs. + * + * Uses debouncing to prevent multiple refreshes in quick succession. + * Mirrors legacy schedule_deferred_pull_jobs_refresh(). + */ + private function schedule_deferred_pull_jobs_refresh(): void { + $debounce_key = 'syn_pull_jobs_refresh_pending'; + + // Skip if already scheduled (debounce). + if ( get_transient( $debounce_key ) ) { + return; + } + + // Set debounce transient (5 second window). + set_transient( $debounce_key, '1', 5 ); + + // Schedule the refresh action. + if ( ! wp_next_scheduled( 'syn_refresh_pull_jobs' ) ) { + wp_schedule_single_event( time() + 5, 'syn_refresh_pull_jobs' ); + } } } diff --git a/tests/Unit/Application/HookRegistrarTest.php b/tests/Unit/Application/HookRegistrarTest.php index 63924c3..b137b22 100644 --- a/tests/Unit/Application/HookRegistrarTest.php +++ b/tests/Unit/Application/HookRegistrarTest.php @@ -123,61 +123,151 @@ public function test_on_cron_schedules_preserves_existing(): void { } /** - * Test on_transition_post_status is callable. + * Test on_transition_post_status skips on autosave. */ - public function test_on_transition_post_status_is_callable(): void { + public function test_on_transition_post_status_skips_autosave(): void { + if ( ! defined( 'DOING_AUTOSAVE' ) ) { + define( 'DOING_AUTOSAVE', true ); + } + $post = Mockery::mock( WP_Post::class ); $post->ID = 123; - // Should not throw. + // Should exit early and not call any functions. $this->registrar->on_transition_post_status( 'publish', 'draft', $post ); $this->assertTrue( true ); } /** - * Test on_trash_post is callable. + * Test on_trash_post skips when delete disabled. */ - public function test_on_trash_post_is_callable(): void { + public function test_on_trash_post_skips_when_delete_disabled(): void { + Functions\when( 'get_option' )->justReturn( array() ); + + // Should exit early when delete_pushed_posts not enabled. $this->registrar->on_trash_post( 123 ); $this->assertTrue( true ); } /** - * Test on_save_post is callable. + * Test on_trash_post skips when no slave posts. */ - public function test_on_save_post_is_callable(): void { + public function test_on_trash_post_skips_when_no_slave_posts(): void { + Functions\when( 'get_option' )->justReturn( array( 'delete_pushed_posts' => true ) ); + Functions\when( 'get_post_meta' )->justReturn( array() ); + + $this->registrar->on_trash_post( 123 ); + + $this->assertTrue( true ); + } + + /** + * Test on_save_post handles non-syn_site post type. + */ + public function test_on_save_post_skips_non_syn_site(): void { $post = Mockery::mock( WP_Post::class ); $post->post_type = 'post'; + // Should not trigger pull refresh for regular posts. $this->registrar->on_save_post( 123, $post, true ); $this->assertTrue( true ); } /** - * Test on_delete_post is callable. + * Test on_save_post triggers refresh for syn_site. */ - public function test_on_delete_post_is_callable(): void { + public function test_on_save_post_triggers_refresh_for_syn_site(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + Functions\when( 'get_transient' )->justReturn( false ); + Functions\expect( 'set_transient' )->once(); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\expect( 'wp_schedule_single_event' )->once(); + + $this->registrar->on_save_post( 123, $post, true ); + + $this->assertTrue( true ); + } + + /** + * Test on_delete_post handles non-syn_site post type. + */ + public function test_on_delete_post_skips_non_syn_site(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'post'; + + Functions\when( 'get_post' )->justReturn( $post ); + $this->registrar->on_delete_post( 123 ); $this->assertTrue( true ); } /** - * Test on_create_term is callable. + * Test on_delete_post triggers refresh for syn_site. */ - public function test_on_create_term_is_callable(): void { + public function test_on_delete_post_triggers_refresh_for_syn_site(): void { + $post = Mockery::mock( WP_Post::class ); + $post->post_type = 'syn_site'; + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_transient' )->justReturn( false ); + Functions\expect( 'set_transient' )->once(); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\expect( 'wp_schedule_single_event' )->once(); + + $this->registrar->on_delete_post( 123 ); + + $this->assertTrue( true ); + } + + /** + * Test on_create_term skips non-syn_sitegroup taxonomy. + */ + public function test_on_create_term_skips_non_syn_sitegroup(): void { + // Should not trigger pull refresh for other taxonomies. + $this->registrar->on_create_term( 1, 2, 'category' ); + + $this->assertTrue( true ); + } + + /** + * Test on_create_term triggers refresh for syn_sitegroup. + */ + public function test_on_create_term_triggers_refresh_for_syn_sitegroup(): void { + Functions\when( 'get_transient' )->justReturn( false ); + Functions\expect( 'set_transient' )->once(); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\expect( 'wp_schedule_single_event' )->once(); + $this->registrar->on_create_term( 1, 2, 'syn_sitegroup' ); $this->assertTrue( true ); } /** - * Test on_delete_term is callable. + * Test on_delete_term skips non-syn_sitegroup taxonomy. + */ + public function test_on_delete_term_skips_non_syn_sitegroup(): void { + // Should not trigger pull refresh for other taxonomies. + $this->registrar->on_delete_term( 1, 2, 'category' ); + + $this->assertTrue( true ); + } + + /** + * Test on_delete_term triggers refresh for syn_sitegroup. */ - public function test_on_delete_term_is_callable(): void { + public function test_on_delete_term_triggers_refresh_for_syn_sitegroup(): void { + Functions\when( 'get_transient' )->justReturn( false ); + Functions\expect( 'set_transient' )->once(); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\expect( 'wp_schedule_single_event' )->once(); + $this->registrar->on_delete_term( 1, 2, 'syn_sitegroup' ); $this->assertTrue( true ); From 003974a64dffa5e3216643ba40043570c597ea2e Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Mon, 19 Jan 2026 13:46:44 +0000 Subject: [PATCH 07/16] feat: add async cron handlers for push syndication Add background processing for push operations to handle large networks: - on_schedule_push_content: Schedules cron event for immediate background execution and spawns cron to minimize delay - on_push_content: Executes push via PushService in cron context, handling both selected sites (push) and removed sites (delete) - extract_site_ids: Helper to handle both legacy WP_Post objects and new integer site IDs for backward compatibility This ensures pushing to many sites (50+) doesn't block the editor experience. The push runs as a background task via WordPress cron. Co-Authored-By: Claude Opus 4.5 --- includes/Application/HookRegistrar.php | 87 +++++++++++++ tests/Unit/Application/HookRegistrarTest.php | 130 +++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/includes/Application/HookRegistrar.php b/includes/Application/HookRegistrar.php index 65ce75d..4c6f631 100644 --- a/includes/Application/HookRegistrar.php +++ b/includes/Application/HookRegistrar.php @@ -101,6 +101,12 @@ private function register_syndication_hooks(): void { */ private function register_cron_hooks(): void { $this->hooks->add_filter( 'cron_schedules', array( $this, 'on_cron_schedules' ), 10, 1 ); + + // Push content scheduling and execution. + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $this->hooks->add_action( 'syn_schedule_push_content', array( $this, 'on_schedule_push_content' ), 10, 2 ); + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $this->hooks->add_action( 'syn_push_content', array( $this, 'on_push_content' ), 10, 1 ); } /** @@ -394,6 +400,87 @@ public function on_cron_schedules( array $schedules ): array { return $schedules; } + /** + * Handle syn_schedule_push_content action. + * + * Schedules a cron event to push content in the background. + * This ensures pushing to many sites doesn't block the request. + * + * @param int $post_id Post ID to push. + * @param array{post_ID: int, selected_sites: array, removed_sites: array} $sites Sites data. + */ + public function on_schedule_push_content( int $post_id, array $sites ): void { + unset( $post_id ); + + // Schedule push to run immediately in the background. + // Using time() - 1 ensures it runs on the next cron tick. + wp_schedule_single_event( + time() - 1, + 'syn_push_content', + array( $sites ) + ); + + // Spawn cron immediately if possible (non-blocking). + if ( function_exists( 'spawn_cron' ) ) { + spawn_cron(); + } + } + + /** + * Handle syn_push_content cron action. + * + * Executes the actual push operation in a background context. + * Uses PushService for the new architecture. + * + * @param array{post_ID: int, selected_sites: array, removed_sites: array} $sites Sites data. + */ + public function on_push_content( array $sites ): void { + $post_id = $sites['post_ID'] ?? 0; + + if ( 0 === $post_id ) { + return; + } + + $push_service = $this->container->get( PushService::class ); + \assert( $push_service instanceof PushService ); + + // Push to selected sites. + $selected_site_ids = $this->extract_site_ids( $sites['selected_sites'] ?? array() ); + if ( ! empty( $selected_site_ids ) ) { + $push_service->push_to_sites( $post_id, $selected_site_ids ); + } + + // Delete from removed sites. + $removed_site_ids = $this->extract_site_ids( $sites['removed_sites'] ?? array() ); + foreach ( $removed_site_ids as $site_id ) { + $push_service->delete_from_site( $post_id, $site_id ); + } + } + + /** + * Extract site IDs from sites array. + * + * The sites array can contain either WP_Post objects (legacy) or site IDs (new). + * + * @param array $sites Array of sites. + * @return array Array of site IDs. + */ + private function extract_site_ids( array $sites ): array { + $ids = array(); + + foreach ( $sites as $site ) { + if ( $site instanceof \WP_Post ) { + $ids[] = $site->ID; + } elseif ( is_object( $site ) && isset( $site->ID ) ) { + $ids[] = (int) $site->ID; + } elseif ( is_numeric( $site ) ) { + $ids[] = (int) $site; + } + } + + return array_unique( $ids ); + } + /** * Handle save_post action. * diff --git a/tests/Unit/Application/HookRegistrarTest.php b/tests/Unit/Application/HookRegistrarTest.php index b137b22..467efa8 100644 --- a/tests/Unit/Application/HookRegistrarTest.php +++ b/tests/Unit/Application/HookRegistrarTest.php @@ -10,6 +10,8 @@ namespace Automattic\Syndication\Tests\Unit\Application; use Automattic\Syndication\Application\HookRegistrar; +use Automattic\Syndication\Application\Services\PushService; +use Automattic\Syndication\Domain\Contracts\TransportFactoryInterface; use Automattic\Syndication\Infrastructure\DI\Container; use Automattic\Syndication\Infrastructure\WordPress\HookManager; use Automattic\Syndication\Tests\Unit\TestCase; @@ -56,6 +58,17 @@ protected function setUp(): void { $this->container = new Container(); $this->hooks = new HookManager(); + + // Register PushService for tests that need it. + $this->container->register( + PushService::class, + function ( Container $container ): PushService { + $factory = $container->get( TransportFactoryInterface::class ); + \assert( $factory instanceof TransportFactoryInterface ); + return new PushService( $factory ); + } + ); + $this->registrar = new HookRegistrar( $this->container, $this->hooks ); } @@ -68,6 +81,8 @@ public function test_register_adds_all_hooks(): void { Actions\expectAdded( 'transition_post_status' )->once(); Actions\expectAdded( 'wp_trash_post' )->once(); Filters\expectAdded( 'cron_schedules' )->once(); + Actions\expectAdded( 'syn_schedule_push_content' )->once(); + Actions\expectAdded( 'syn_push_content' )->once(); Actions\expectAdded( 'save_post' )->once(); Actions\expectAdded( 'delete_post' )->once(); Actions\expectAdded( 'create_term' )->once(); @@ -313,4 +328,119 @@ public function test_on_admin_init_is_callable(): void { $this->assertTrue( true ); } + + /** + * Test on_schedule_push_content schedules cron event. + */ + public function test_on_schedule_push_content_schedules_cron(): void { + $sites = array( + 'post_ID' => 123, + 'selected_sites' => array( 1, 2, 3 ), + 'removed_sites' => array(), + ); + + Functions\expect( 'wp_schedule_single_event' ) + ->once() + ->with( Mockery::type( 'int' ), 'syn_push_content', array( $sites ) ); + Functions\when( 'spawn_cron' )->justReturn( null ); + + $this->registrar->on_schedule_push_content( 123, $sites ); + + $this->assertTrue( true ); + } + + /** + * Test on_schedule_push_content spawns cron. + */ + public function test_on_schedule_push_content_spawns_cron(): void { + $sites = array( + 'post_ID' => 123, + 'selected_sites' => array(), + 'removed_sites' => array(), + ); + + Functions\when( 'wp_schedule_single_event' )->justReturn( true ); + Functions\expect( 'spawn_cron' )->once(); + + $this->registrar->on_schedule_push_content( 123, $sites ); + + $this->assertTrue( true ); + } + + /** + * Test on_push_content skips when no post_ID. + */ + public function test_on_push_content_skips_when_no_post_id(): void { + $sites = array( + 'selected_sites' => array( 1 ), + 'removed_sites' => array(), + ); + + // Should exit early without calling PushService. + $this->registrar->on_push_content( $sites ); + + $this->assertTrue( true ); + } + + /** + * Test on_push_content pushes to selected sites. + */ + public function test_on_push_content_pushes_to_selected_sites(): void { + $sites = array( + 'post_ID' => 123, + 'selected_sites' => array( 1, 2 ), + 'removed_sites' => array(), + ); + + // The PushService is registered in the container and will be called. + // For this test, we just verify the method runs without error. + // Full integration testing verifies the actual push. + Functions\when( 'get_post' )->justReturn( null ); + Functions\when( 'get_transient' )->justReturn( 'locked' ); + + $this->registrar->on_push_content( $sites ); + + $this->assertTrue( true ); + } + + /** + * Test on_push_content handles WP_Post objects in sites array. + */ + public function test_on_push_content_handles_wp_post_objects(): void { + $site1 = Mockery::mock( WP_Post::class ); + $site1->ID = 1; + $site2 = Mockery::mock( WP_Post::class ); + $site2->ID = 2; + + $sites = array( + 'post_ID' => 123, + 'selected_sites' => array( $site1, $site2 ), + 'removed_sites' => array(), + ); + + Functions\when( 'get_post' )->justReturn( null ); + Functions\when( 'get_transient' )->justReturn( 'locked' ); + + $this->registrar->on_push_content( $sites ); + + $this->assertTrue( true ); + } + + /** + * Test on_push_content deletes from removed sites. + */ + public function test_on_push_content_deletes_from_removed_sites(): void { + $sites = array( + 'post_ID' => 123, + 'selected_sites' => array(), + 'removed_sites' => array( 3, 4 ), + ); + + Functions\when( 'get_post' )->justReturn( null ); + Functions\when( 'get_post_meta' )->justReturn( array() ); + + $this->registrar->on_push_content( $sites ); + + $this->assertTrue( true ); + } } From 3c103dedb8b7fcb78f4c1236280c71237650281c Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Mon, 19 Jan 2026 14:47:08 +0000 Subject: [PATCH 08/16] feat: add async cron handlers for pull syndication Implements async pull handlers in HookRegistrar to mirror the existing push architecture. The legacy plugin manages pull operations through scheduled cron jobs that need migration to the new service-based approach. Adds syn_pull_content hook handler that delegates to PullService for async content pulling from remote sites, with support for configurable update behaviour. Adds syn_refresh_pull_jobs handler to reschedule all pull jobs when site configuration changes, ensuring the cron schedule stays synchronised with site and sitegroup modifications. Includes helper methods for retrieving configured pull sites from sitegroups and scheduling individual cron jobs per site, replacing the legacy batch scheduling approach. Co-Authored-By: Claude Opus 4.5 --- includes/Application/HookRegistrar.php | 144 ++++++++++++++++++ .../Application/HookRegistrationTest.php | 18 +++ tests/Unit/Application/HookRegistrarTest.php | 101 ++++++++++++ 3 files changed, 263 insertions(+) diff --git a/includes/Application/HookRegistrar.php b/includes/Application/HookRegistrar.php index 4c6f631..26b0189 100644 --- a/includes/Application/HookRegistrar.php +++ b/includes/Application/HookRegistrar.php @@ -9,6 +9,7 @@ namespace Automattic\Syndication\Application; +use Automattic\Syndication\Application\Services\PullService; use Automattic\Syndication\Application\Services\PushService; use Automattic\Syndication\Domain\Contracts\SiteRepositoryInterface; use Automattic\Syndication\Infrastructure\DI\Container; @@ -107,6 +108,12 @@ private function register_cron_hooks(): void { $this->hooks->add_action( 'syn_schedule_push_content', array( $this, 'on_schedule_push_content' ), 10, 2 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. $this->hooks->add_action( 'syn_push_content', array( $this, 'on_push_content' ), 10, 1 ); + + // Pull content execution and job management. + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $this->hooks->add_action( 'syn_pull_content', array( $this, 'on_pull_content' ), 10, 1 ); + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $this->hooks->add_action( 'syn_refresh_pull_jobs', array( $this, 'on_refresh_pull_jobs' ), 10, 0 ); } /** @@ -481,6 +488,143 @@ private function extract_site_ids( array $sites ): array { return array_unique( $ids ); } + /** + * Handle syn_pull_content cron action. + * + * Pulls content from remote sites using PullService. + * Legacy code passes an array of WP_Post site objects or a single site in an array. + * + * @param array<\WP_Post|int> $sites Array of sites to pull from. + */ + public function on_pull_content( array $sites ): void { + if ( empty( $sites ) ) { + $sites = $this->get_selected_pull_sites(); + } + + $site_ids = $this->extract_site_ids( $sites ); + + if ( empty( $site_ids ) ) { + return; + } + + // Configure update behavior from settings. + $settings = get_option( 'push_syndicate_settings' ); + $update_existing = ! empty( $settings['update_pulled_posts'] ) && 'on' === $settings['update_pulled_posts']; + + $pull_service = $this->container->get( PullService::class ); + \assert( $pull_service instanceof PullService ); + $pull_service->set_update_existing( $update_existing ); + + // Pull from all sites. + $pull_service->pull_from_sites( $site_ids ); + } + + /** + * Handle syn_refresh_pull_jobs cron action. + * + * Reschedules all pull jobs based on current site configuration. + * Called when sites or sitegroups are modified. + */ + public function on_refresh_pull_jobs(): void { + $sites = $this->get_selected_pull_sites(); + + $this->schedule_pull_jobs( $sites ); + } + + /** + * Get sites selected for pulling. + * + * Returns sites from the selected pull sitegroups, ordered by last pull time. + * + * @return array<\WP_Post> Array of site post objects. + */ + private function get_selected_pull_sites(): array { + $settings = get_option( 'push_syndicate_settings' ); + + if ( empty( $settings['selected_pull_sitegroups'] ) ) { + return array(); + } + + $selected_sitegroups = $settings['selected_pull_sitegroups']; + $sites = array(); + + foreach ( $selected_sitegroups as $sitegroup ) { + $term = get_term_by( 'slug', $sitegroup, 'syn_sitegroup' ); + if ( ! $term instanceof \WP_Term ) { + continue; + } + + $query = new \WP_Query( + array( + 'post_type' => PostTypeRegistrar::POST_TYPE, + 'posts_per_page' => 100, + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Required for sitegroup filtering. + array( + 'taxonomy' => PostTypeRegistrar::TAXONOMY, + 'field' => 'slug', + 'terms' => $sitegroup, + ), + ), + ) + ); + + $sites = array_merge( $sites, $query->posts ); + } + + // Sort by last pull time (oldest first). + usort( + $sites, + static function ( \WP_Post $a, \WP_Post $b ): int { + $a_time = (int) get_post_meta( $a->ID, 'syn_last_pull_time', true ); + $b_time = (int) get_post_meta( $b->ID, 'syn_last_pull_time', true ); + return $a_time <=> $b_time; + } + ); + + return $sites; + } + + /** + * Schedule pull jobs for sites. + * + * Clears existing pull cron jobs and schedules new ones (one per site). + * + * @param array<\WP_Post> $sites Array of site post objects. + */ + private function schedule_pull_jobs( array $sites ): void { + // Get old sites to clear their scheduled jobs. + $old_sites = get_option( 'syn_old_pull_sites', array() ); + + // Clear old scheduled jobs. + if ( ! empty( $old_sites ) ) { + // Clear jobs scheduled the old way (one job for many sites). + wp_clear_scheduled_hook( 'syn_pull_content', array( $old_sites ) ); + + // Clear jobs scheduled the new way (one job per site). + foreach ( $old_sites as $old_site ) { + wp_clear_scheduled_hook( 'syn_pull_content', array( $old_site ) ); + // Also clear single-site array format. + wp_clear_scheduled_hook( 'syn_pull_content', array( array( $old_site ) ) ); + } + + // Clear any generic scheduled hook. + wp_clear_scheduled_hook( 'syn_pull_content' ); + } + + // Schedule new jobs: one job per site. + foreach ( $sites as $site ) { + wp_schedule_event( + time() - 1, + 'syn_pull_time_interval', + 'syn_pull_content', + array( array( $site ) ) + ); + } + + // Save sites for next refresh. + update_option( 'syn_old_pull_sites', $sites ); + } + /** * Handle save_post action. * diff --git a/tests/Integration/Application/HookRegistrationTest.php b/tests/Integration/Application/HookRegistrationTest.php index 66a9d18..f0fba9e 100644 --- a/tests/Integration/Application/HookRegistrationTest.php +++ b/tests/Integration/Application/HookRegistrationTest.php @@ -127,6 +127,24 @@ public function test_syn_get_container_hook_is_registered(): void { $this->assertNotFalse( has_action( 'syn_get_container' ) ); } + /** + * Test syn_pull_content hook is registered. + */ + public function test_syn_pull_content_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'syn_pull_content', array( $this->registrar, 'on_pull_content' ) ) + ); + } + + /** + * Test syn_refresh_pull_jobs hook is registered. + */ + public function test_syn_refresh_pull_jobs_hook_is_registered(): void { + $this->assertIsInt( + has_action( 'syn_refresh_pull_jobs', array( $this->registrar, 'on_refresh_pull_jobs' ) ) + ); + } + /** * Test cron_schedules filter adds syn_pull_time_interval. */ diff --git a/tests/Unit/Application/HookRegistrarTest.php b/tests/Unit/Application/HookRegistrarTest.php index 467efa8..c5b6321 100644 --- a/tests/Unit/Application/HookRegistrarTest.php +++ b/tests/Unit/Application/HookRegistrarTest.php @@ -10,6 +10,7 @@ namespace Automattic\Syndication\Tests\Unit\Application; use Automattic\Syndication\Application\HookRegistrar; +use Automattic\Syndication\Application\Services\PullService; use Automattic\Syndication\Application\Services\PushService; use Automattic\Syndication\Domain\Contracts\TransportFactoryInterface; use Automattic\Syndication\Infrastructure\DI\Container; @@ -69,6 +70,16 @@ function ( Container $container ): PushService { } ); + // Register PullService for tests that need it. + $this->container->register( + PullService::class, + function ( Container $container ): PullService { + $factory = $container->get( TransportFactoryInterface::class ); + \assert( $factory instanceof TransportFactoryInterface ); + return new PullService( $factory ); + } + ); + $this->registrar = new HookRegistrar( $this->container, $this->hooks ); } @@ -83,6 +94,8 @@ public function test_register_adds_all_hooks(): void { Filters\expectAdded( 'cron_schedules' )->once(); Actions\expectAdded( 'syn_schedule_push_content' )->once(); Actions\expectAdded( 'syn_push_content' )->once(); + Actions\expectAdded( 'syn_pull_content' )->once(); + Actions\expectAdded( 'syn_refresh_pull_jobs' )->once(); Actions\expectAdded( 'save_post' )->once(); Actions\expectAdded( 'delete_post' )->once(); Actions\expectAdded( 'create_term' )->once(); @@ -443,4 +456,92 @@ public function test_on_push_content_deletes_from_removed_sites(): void { $this->assertTrue( true ); } + + /** + * Test on_pull_content skips when no sites. + */ + public function test_on_pull_content_skips_when_no_sites(): void { + Functions\when( 'get_option' )->justReturn( array() ); + + // Should exit early when no sites provided and no selected sitegroups. + $this->registrar->on_pull_content( array() ); + + $this->assertTrue( true ); + } + + /** + * Test on_pull_content processes site IDs. + */ + public function test_on_pull_content_processes_site_ids(): void { + Functions\when( 'get_option' )->justReturn( + array( 'update_pulled_posts' => 'on' ) + ); + Functions\when( 'get_post' )->justReturn( null ); + Functions\when( 'wp_defer_term_counting' )->justReturn( null ); + Functions\when( 'wp_defer_comment_counting' )->justReturn( null ); + Functions\when( 'wp_suspend_cache_invalidation' )->justReturn( null ); + Functions\when( 'get_post_meta' )->justReturn( '' ); + + $this->registrar->on_pull_content( array( 1, 2 ) ); + + $this->assertTrue( true ); + } + + /** + * Test on_pull_content handles WP_Post objects. + */ + public function test_on_pull_content_handles_wp_post_objects(): void { + $site1 = Mockery::mock( WP_Post::class ); + $site1->ID = 1; + $site2 = Mockery::mock( WP_Post::class ); + $site2->ID = 2; + + Functions\when( 'get_option' )->justReturn( + array( 'update_pulled_posts' => 'on' ) + ); + Functions\when( 'get_post' )->justReturn( null ); + Functions\when( 'wp_defer_term_counting' )->justReturn( null ); + Functions\when( 'wp_defer_comment_counting' )->justReturn( null ); + Functions\when( 'wp_suspend_cache_invalidation' )->justReturn( null ); + Functions\when( 'get_post_meta' )->justReturn( '' ); + + $this->registrar->on_pull_content( array( $site1, $site2 ) ); + + $this->assertTrue( true ); + } + + /** + * Test on_refresh_pull_jobs schedules jobs. + */ + public function test_on_refresh_pull_jobs_schedules_jobs(): void { + Functions\when( 'get_option' )->justReturn( array() ); + Functions\when( 'wp_clear_scheduled_hook' )->justReturn( 0 ); + Functions\when( 'update_option' )->justReturn( true ); + + // Should run without error when no selected sitegroups. + $this->registrar->on_refresh_pull_jobs(); + + $this->assertTrue( true ); + } + + /** + * Test on_refresh_pull_jobs with sitegroups. + */ + public function test_on_refresh_pull_jobs_with_sitegroups(): void { + Functions\when( 'get_option' )->alias( + function ( $option ) { + if ( 'push_syndicate_settings' === $option ) { + return array( 'selected_pull_sitegroups' => array( 'group1' ) ); + } + return array(); + } + ); + Functions\when( 'get_term_by' )->justReturn( false ); + Functions\when( 'wp_clear_scheduled_hook' )->justReturn( 0 ); + Functions\when( 'update_option' )->justReturn( true ); + + $this->registrar->on_refresh_pull_jobs(); + + $this->assertTrue( true ); + } } From 3c8c5410f173c3d9338f6810920c975333606c9a Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Fri, 23 Jan 2026 08:40:38 +0000 Subject: [PATCH 09/16] feat: consolidate logging system with per-sync entries Replaces granular per-post logging with consolidated per-sync-run entries to reduce noise and improve log clarity. The previous system logged every single post operation separately, making it difficult to understand overall sync outcomes and cluttering the interface with redundant information when nothing had actually changed. Key improvements: - Consolidated logging: One log entry per pull sync run (per site) and one entry per push operation (covering all target sites), rather than individual entries for every post - Content hash detection: Skips logging "updated" actions when post content hasn't actually changed, eliminating false positives from identical syncs - Separate admin interfaces: Dedicated Pull Logs and Push Logs pages with filtering, search, and visual status badges for easier troubleshooting - Per-site configuration: Configure default post status for pulled content (draft/pending/publish) and customisable log retention limits (1-1000 entries per site) - Remote post links: Links now point to front-end URLs rather than admin URLs, as syndicated users may not have admin access on remote sites - Smart legacy handling: Legacy logs page only appears when old logs exist (moved to menu priority 20), with clear messaging directing users to new log pages Technical changes: - Adds SyndicationLog singleton with structured logging methods and status constants (success/partial/error/skipped) - Pull logs stored in post meta per-site, push logs stored in options table - Updates PullService and PushService to integrate consolidated logging throughout sync workflows - Implements PullLogViewer and PushLogViewer admin interfaces with proper list tables, pagination, and filtering - Updates tests to account for new PullServiceInterface Co-Authored-By: Claude Opus 4.5 --- includes/Application/Services/PullService.php | 123 +- includes/Application/Services/PushService.php | 49 +- .../Infrastructure/Logging/PullLogViewer.php | 368 ++++++ .../Infrastructure/Logging/PushLogViewer.php | 317 +++++ .../Infrastructure/Logging/SyndicationLog.php | 558 ++++++++ includes/class-syndication-logger-viewer.php | 61 +- includes/class-wp-push-syndication-server.php | 1128 +++++++++++++---- push-syndication.php | 63 +- .../Application/Services/PullServiceTest.php | 25 +- 9 files changed, 2376 insertions(+), 316 deletions(-) create mode 100644 includes/Infrastructure/Logging/PullLogViewer.php create mode 100644 includes/Infrastructure/Logging/PushLogViewer.php create mode 100644 includes/Infrastructure/Logging/SyndicationLog.php diff --git a/includes/Application/Services/PullService.php b/includes/Application/Services/PullService.php index 4200280..623856b 100644 --- a/includes/Application/Services/PullService.php +++ b/includes/Application/Services/PullService.php @@ -9,9 +9,11 @@ namespace Automattic\Syndication\Application\Services; +use Automattic\Syndication\Application\Contracts\PullServiceInterface; use Automattic\Syndication\Application\DTO\PullResult; use Automattic\Syndication\Domain\Contracts\PullTransportInterface; use Automattic\Syndication\Domain\Contracts\TransportFactoryInterface; +use Automattic\Syndication\Infrastructure\Logging\SyndicationLog; use WP_Post; /** @@ -20,7 +22,7 @@ * Orchestrates the pull syndication workflow including transport creation, * post import/update, and result tracking. */ -final class PullService { +final class PullService implements PullServiceInterface { /** * Transport factory. @@ -86,20 +88,26 @@ public function pull_from_sites( array $site_ids ): array { */ public function pull_from_site( int $site_id ): PullResult { $site = get_post( $site_id ); + $log = SyndicationLog::instance(); if ( ! $site instanceof WP_Post || 'syn_site' !== $site->post_type ) { return PullResult::failure( $site_id, 'invalid_site', 'Site not found.' ); } + // Start logging for this pull. + $log->start_pull( $site_id, $site->post_title ); + // Check if site is enabled. $enabled = get_post_meta( $site_id, 'syn_site_enabled', true ); if ( 'on' !== $enabled ) { + $log->end_pull( 'Site is disabled.' ); return PullResult::skipped( $site_id, 'Site is disabled.' ); } $transport = $this->transport_factory->create_pull_transport( $site_id ); if ( ! $transport instanceof PullTransportInterface ) { + $log->end_pull( 'Could not create pull transport.' ); return PullResult::failure( $site_id, 'invalid_transport', 'Could not create pull transport.' ); } @@ -111,6 +119,7 @@ public function pull_from_site( int $site_id ): PullResult { if ( empty( $posts ) ) { $this->update_last_pull_time( $site_id ); + $log->end_pull(); return PullResult::success( $site_id, 0, 0 ); } @@ -122,6 +131,9 @@ public function pull_from_site( int $site_id ): PullResult { foreach ( $posts as $post_data ) { $result = $this->process_pulled_post( $post_data, $site_id, $site ); + // Log the result. + $this->log_pulled_post_result( $log, $result, $post_data ); + switch ( $result['status'] ) { case 'created': ++$created; @@ -139,6 +151,7 @@ public function pull_from_site( int $site_id ): PullResult { } $this->update_last_pull_time( $site_id ); + $log->end_pull(); if ( ! empty( $errors ) ) { return PullResult::partial( $site_id, $created, $updated, $skipped, $errors ); @@ -147,6 +160,55 @@ public function pull_from_site( int $site_id ): PullResult { return PullResult::success( $site_id, $created, $updated ); } + /** + * Log a pulled post result. + * + * @param SyndicationLog $log The log instance. + * @param array $result The processing result. + * @param array $post_data The original post data. + */ + private function log_pulled_post_result( SyndicationLog $log, array $result, array $post_data ): void { + $status = $result['status']; + $local_id = $result['post_id'] ?? 0; + $remote_id = $this->extract_remote_id( $post_data ); + $title = $post_data['post_title'] ?? ''; + $error = 'error' === $status ? ( $result['message'] ?? null ) : null; + + // Map status to log action. + $action_map = array( + 'created' => SyndicationLog::ACTION_CREATED, + 'updated' => SyndicationLog::ACTION_UPDATED, + 'skipped' => SyndicationLog::ACTION_SKIPPED, + 'error' => SyndicationLog::ACTION_FAILED, + ); + + $action = $action_map[ $status ] ?? SyndicationLog::ACTION_FAILED; + + $log->log_pulled_post( $action, $local_id, $remote_id, $title, $error ); + } + + /** + * Extract the remote post ID from post data. + * + * @param array $post_data The post data. + * @return int Remote post ID. + */ + private function extract_remote_id( array $post_data ): int { + // Try direct ID field first. + if ( ! empty( $post_data['ID'] ) ) { + return (int) $post_data['ID']; + } + + // Try to extract from GUID (e.g., "https://example.com/?p=123"). + if ( ! empty( $post_data['post_guid'] ) ) { + if ( preg_match( '/[?&]p=(\d+)/', $post_data['post_guid'], $matches ) ) { + return (int) $matches[1]; + } + } + + return 0; + } + /** * Process a single pulled post. * @@ -198,7 +260,7 @@ private function create_new_post( array $post_data, int $site_id, WP_Post $site $post_data = apply_filters( 'syn_pull_new_post', $post_data, $site, null ); // Prepare data for wp_insert_post. - $insert_data = $this->prepare_insert_data( $post_data ); + $insert_data = $this->prepare_insert_data( $post_data, $site_id ); $result = wp_insert_post( $insert_data, true ); if ( is_wp_error( $result ) ) { @@ -214,6 +276,7 @@ private function create_new_post( array $post_data, int $site_id, WP_Post $site // Store syndication metadata. update_post_meta( $post_id, 'syn_post_guid', $post_data['post_guid'] ); update_post_meta( $post_id, 'syn_source_site_id', $site_id ); + update_post_meta( $post_id, 'syn_content_hash', $this->generate_content_hash( $post_data ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. do_action( 'syn_post_pull_new_post', $post_id, $post_data, $site, '', null ); @@ -254,13 +317,25 @@ private function update_existing_post( int $post_id, array $post_data, int $site ); } + // Check if content has actually changed. + $new_hash = $this->generate_content_hash( $post_data ); + $stored_hash = get_post_meta( $post_id, 'syn_content_hash', true ); + + if ( $new_hash === $stored_hash ) { + return array( + 'status' => 'skipped', + 'message' => 'No changes.', + 'post_id' => $post_id, + ); + } + $post_data['ID'] = $post_id; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. $post_data = apply_filters( 'syn_pull_edit_post', $post_data, $site, null ); // Prepare data for wp_update_post. - $update_data = $this->prepare_insert_data( $post_data ); + $update_data = $this->prepare_insert_data( $post_data, $site_id ); $update_data['ID'] = $post_id; $result = wp_update_post( $update_data, true ); @@ -273,6 +348,9 @@ private function update_existing_post( int $post_id, array $post_data, int $site ); } + // Store the new content hash. + update_post_meta( $post_id, 'syn_content_hash', $new_hash ); + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook for backward compatibility. do_action( 'syn_post_pull_edit_post', $post_id, $post_data, $site, '', null ); @@ -287,14 +365,25 @@ private function update_existing_post( int $post_id, array $post_data, int $site * Prepare data for wp_insert_post/wp_update_post. * * @param array $post_data Pulled post data. + * @param int $site_id Site post ID for per-site settings. * @return array Data for WordPress post functions. */ - private function prepare_insert_data( array $post_data ): array { + private function prepare_insert_data( array $post_data, int $site_id ): array { + // Check for per-site default post status override. + $default_status = get_post_meta( $site_id, 'syn_pull_post_status', true ); + if ( empty( $default_status ) ) { + // Use remote status or fall back to draft. + $post_status = $post_data['post_status'] ?? 'draft'; + } else { + // Override with per-site setting. + $post_status = $default_status; + } + $data = array( 'post_title' => $post_data['post_title'] ?? '', 'post_content' => $post_data['post_content'] ?? '', 'post_excerpt' => $post_data['post_excerpt'] ?? '', - 'post_status' => $post_data['post_status'] ?? 'draft', + 'post_status' => $post_status, 'post_type' => $post_data['post_type'] ?? 'post', ); @@ -364,4 +453,28 @@ private function end_import(): void { wp_defer_term_counting( false ); wp_defer_comment_counting( false ); } + + /** + * Generate a content hash for change detection. + * + * Creates a hash from the key content fields to detect if the + * remote post has actually changed since the last pull. + * + * @param array $post_data The post data. + * @return string MD5 hash of the content. + */ + private function generate_content_hash( array $post_data ): string { + $content_to_hash = implode( + '|', + array( + $post_data['post_title'] ?? '', + $post_data['post_content'] ?? '', + $post_data['post_excerpt'] ?? '', + $post_data['post_status'] ?? '', + $post_data['post_date'] ?? '', + ) + ); + + return md5( $content_to_hash ); + } } diff --git a/includes/Application/Services/PushService.php b/includes/Application/Services/PushService.php index ffcdd7f..c924d81 100644 --- a/includes/Application/Services/PushService.php +++ b/includes/Application/Services/PushService.php @@ -9,9 +9,11 @@ namespace Automattic\Syndication\Application\Services; +use Automattic\Syndication\Application\Contracts\PushServiceInterface; use Automattic\Syndication\Application\DTO\PushResult; use Automattic\Syndication\Domain\Contracts\PushTransportInterface; use Automattic\Syndication\Domain\Contracts\TransportFactoryInterface; +use Automattic\Syndication\Infrastructure\Logging\SyndicationLog; use WP_Error; use WP_Post; @@ -21,7 +23,7 @@ * Orchestrates the push syndication workflow including transport creation, * state management, and result tracking. */ -final class PushService { +final class PushService implements PushServiceInterface { /** * Lock transient name. @@ -67,22 +69,65 @@ public function push_to_sites( int $post_id, array $site_ids ): array { return array(); } + $log = SyndicationLog::instance(); + $log->start_push( $post_id, $post->post_title ); + try { $results = array(); $slave_states = $this->get_slave_post_states( $post_id ); foreach ( $site_ids as $site_id ) { - $results[ $site_id ] = $this->push_to_site( $post_id, $site_id, $slave_states ); + $result = $this->push_to_site( $post_id, $site_id, $slave_states ); + $results[ $site_id ] = $result; + + // Log the result. + $this->log_push_result( $log, $result, $site_id ); } $this->save_slave_post_states( $post_id, $slave_states ); + $log->end_push(); + return $results; } finally { $this->release_lock(); } } + /** + * Log a push result. + * + * @param SyndicationLog $log The log instance. + * @param PushResult $result The push result. + * @param int $site_id Site post ID. + */ + private function log_push_result( SyndicationLog $log, PushResult $result, int $site_id ): void { + $site = get_post( $site_id ); + $site_name = $site instanceof WP_Post ? $site->post_title : __( 'Unknown site', 'push-syndication' ); + + // Map result status to log action. + $action_map = array( + 'created' => SyndicationLog::ACTION_CREATED, + 'updated' => SyndicationLog::ACTION_UPDATED, + 'deleted' => SyndicationLog::ACTION_DELETED, + ); + + if ( $result->is_success() ) { + $action = $action_map[ $result->action ] ?? SyndicationLog::ACTION_UPDATED; + $log->log_pushed_site( $site_id, $site_name, $action, $result->remote_id ); + } elseif ( $result->is_skipped() ) { + $log->log_pushed_site( $site_id, $site_name, SyndicationLog::ACTION_SKIPPED, 0 ); + } else { + $log->log_pushed_site( + $site_id, + $site_name, + SyndicationLog::ACTION_FAILED, + 0, + $result->message + ); + } + } + /** * Push a post to a single site. * diff --git a/includes/Infrastructure/Logging/PullLogViewer.php b/includes/Infrastructure/Logging/PullLogViewer.php new file mode 100644 index 0000000..f67a89a --- /dev/null +++ b/includes/Infrastructure/Logging/PullLogViewer.php @@ -0,0 +1,368 @@ +log = SyndicationLog::instance(); + } + + /** + * Register the admin menu. + */ + public function register(): void { + add_action( 'admin_menu', array( $this, 'add_menu_page' ) ); + } + + /** + * Add the Pull Logs submenu page. + */ + public function add_menu_page(): void { + add_submenu_page( + 'edit.php?post_type=syn_site', + __( 'Pull Logs', 'push-syndication' ), + __( 'Pull Logs', 'push-syndication' ), + 'manage_options', + 'syndication-pull-logs', + array( $this, 'render_page' ) + ); + } + + /** + * Render the Pull Logs page. + */ + public function render_page(): void { + // Get filter parameters. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $site_id = isset( $_GET['site'] ) ? absint( $_GET['site'] ) : null; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $timestamp = isset( $_GET['time'] ) ? absint( $_GET['time'] ) : null; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $status = isset( $_GET['status'] ) ? sanitize_text_field( wp_unslash( $_GET['status'] ) ) : null; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : null; + + // Get logs. + $logs = $this->log->get_pull_logs( + $site_id ?: null, + $timestamp ?: null, + $status ?: null, + $search ?: null + ); + + // Get sites for filter dropdown. + $sites = $this->log->get_sites_with_logs(); + + // Get all sites for the dropdown (including those without logs yet). + $all_sites = get_posts( + array( + 'post_type' => 'syn_site', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + ?> +
+

+ +
+ + + +
+
+ + + + + + + + + + + + + +
+ + +
+
+ + +

+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + 'syn_site', + 'page' => 'syndication-pull-logs', + 'time' => $entry['timestamp'], + 'site' => $entry['site_id'], + ), + admin_url( 'edit.php' ) + ); + ?> + + format_time( $entry['time'] ) ); ?> + + + render_status_badge( $entry['status'] ) ); ?> + + render_pull_result( $entry ) ); ?> +
+ +
+ ' ' . esc_html__( 'Success', 'push-syndication' ), + 'partial' => ' ' . esc_html__( 'Partial', 'push-syndication' ), + 'error' => ' ' . esc_html__( 'Error', 'push-syndication' ), + 'skipped' => ' ' . esc_html__( 'Skipped', 'push-syndication' ), + ); + + return $badges[ $status ] ?? esc_html( $status ); + } + + /** + * Render the result column for a pull log entry. + * + * @param array $entry The log entry. + * @return string HTML for the result. + */ + private function render_pull_result( array $entry ): string { + // If there's an overall error, show it. + if ( ! empty( $entry['error'] ) ) { + return '' . esc_html( $entry['error'] ) . ''; + } + + $posts = $entry['posts'] ?? array(); + $summary = $entry['summary'] ?? array(); + + // If no posts, show summary. + if ( empty( $posts ) ) { + if ( 'skipped' === $entry['status'] ) { + return esc_html__( 'No posts to process.', 'push-syndication' ); + } + return esc_html__( 'No changes.', 'push-syndication' ); + } + + // Group posts by action. + $grouped = array( + 'created' => array(), + 'updated' => array(), + 'skipped' => array(), + 'failed' => array(), + ); + + foreach ( $posts as $post ) { + $action = $post['action'] ?? 'unknown'; + if ( isset( $grouped[ $action ] ) ) { + $grouped[ $action ][] = $post; + } + } + + $parts = array(); + + // Created posts. + if ( ! empty( $grouped['created'] ) ) { + $links = array(); + foreach ( $grouped['created'] as $post ) { + $links[] = $this->render_post_link( $post, $entry ); + } + $parts[] = '' . esc_html__( 'Created:', 'push-syndication' ) . ' ' . implode( ', ', $links ); + } + + // Updated posts. + if ( ! empty( $grouped['updated'] ) ) { + $links = array(); + foreach ( $grouped['updated'] as $post ) { + $links[] = $this->render_post_link( $post, $entry ); + } + $parts[] = '' . esc_html__( 'Updated:', 'push-syndication' ) . ' ' . implode( ', ', $links ); + } + + // Skipped posts (show count only to reduce noise). + if ( ! empty( $grouped['skipped'] ) ) { + $count = count( $grouped['skipped'] ); + $parts[] = '' . esc_html__( 'Skipped:', 'push-syndication' ) . ' ' . + sprintf( + /* translators: %d: number of posts skipped */ + esc_html( _n( '%d post (no changes)', '%d posts (no changes)', $count, 'push-syndication' ) ), + $count + ); + } + + // Failed posts. + if ( ! empty( $grouped['failed'] ) ) { + $links = array(); + foreach ( $grouped['failed'] as $post ) { + $error = $post['error'] ?? __( 'Unknown error', 'push-syndication' ); + $links[] = '' . + esc_html( $post['title'] ?? '#' . $post['remote_id'] ) . + ' (' . esc_html( $error ) . ')'; + } + $parts[] = '' . esc_html__( 'Failed:', 'push-syndication' ) . ' ' . implode( ', ', $links ); + } + + return implode( '
', $parts ); + } + + /** + * Render a post link with local and remote IDs. + * + * @param array $post The post data. + * @param array $entry The log entry. + * @return string HTML for the link. + */ + private function render_post_link( array $post, array $entry ): string { + $local_id = $post['local_id'] ?? 0; + $remote_id = $post['remote_id'] ?? 0; + $title = $post['title'] ?? ''; + + // Build local link (to edit screen). + $local_link = ''; + if ( $local_id > 0 ) { + $local_url = admin_url( 'post.php?post=' . $local_id . '&action=edit' ); + $local_link = '#' . $local_id . ''; + } + + // Build remote link (to front-end, not admin - user may not have access). + $remote_link = ''; + if ( $remote_id > 0 ) { + $site_url = get_post_meta( $entry['site_id'], 'syn_site_url', true ); + if ( $site_url ) { + $remote_url = trailingslashit( $site_url ) . '?p=' . $remote_id; + $remote_link = '#' . $remote_id . ' '; + } else { + $remote_link = '#' . $remote_id; + } + } + + // Format: Local #12 ← Remote #103 + if ( $local_link && $remote_link ) { + return $local_link . ' ' . $remote_link; + } elseif ( $local_link ) { + return $local_link; + } elseif ( $remote_link ) { + return $remote_link; + } + + return esc_html( $title ?: __( 'Unknown post', 'push-syndication' ) ); + } +} diff --git a/includes/Infrastructure/Logging/PushLogViewer.php b/includes/Infrastructure/Logging/PushLogViewer.php new file mode 100644 index 0000000..e0f505b --- /dev/null +++ b/includes/Infrastructure/Logging/PushLogViewer.php @@ -0,0 +1,317 @@ +log = SyndicationLog::instance(); + } + + /** + * Register the admin menu. + */ + public function register(): void { + add_action( 'admin_menu', array( $this, 'add_menu_page' ) ); + } + + /** + * Add the Push Logs submenu page. + */ + public function add_menu_page(): void { + add_submenu_page( + 'edit.php?post_type=syn_site', + __( 'Push Logs', 'push-syndication' ), + __( 'Push Logs', 'push-syndication' ), + 'manage_options', + 'syndication-push-logs', + array( $this, 'render_page' ) + ); + } + + /** + * Render the Push Logs page. + */ + public function render_page(): void { + // Get filter parameters. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : null; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $timestamp = isset( $_GET['time'] ) ? absint( $_GET['time'] ) : null; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $status = isset( $_GET['status'] ) ? sanitize_text_field( wp_unslash( $_GET['status'] ) ) : null; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only filter params. + $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : null; + + // Get logs. + $logs = $this->log->get_push_logs( + $post_id ?: null, + $timestamp ?: null, + $status ?: null, + $search ?: null + ); + + ?> +
+

+ +
+ + + +
+
+ + + + + + + + + + +
+ + +
+
+ + +

+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + +
+ # +
+ 'syn_site', + 'page' => 'syndication-push-logs', + 'time' => $entry['timestamp'], + 'post' => $entry['post_id'], + ), + admin_url( 'edit.php' ) + ); + ?> + + format_time( $entry['time'] ) ); ?> + + + render_status_badge( $entry['status'] ) ); ?> + + render_push_result( $entry ) ); ?> +
+ +
+ ' ' . esc_html__( 'Success', 'push-syndication' ), + 'partial' => ' ' . esc_html__( 'Partial', 'push-syndication' ), + 'error' => ' ' . esc_html__( 'Error', 'push-syndication' ), + ); + + return $badges[ $status ] ?? esc_html( $status ); + } + + /** + * Render the result column for a push log entry. + * + * @param array $entry The log entry. + * @return string HTML for the result. + */ + private function render_push_result( array $entry ): string { + $sites = $entry['sites'] ?? array(); + + if ( empty( $sites ) ) { + return esc_html__( 'No sites.', 'push-syndication' ); + } + + // Group sites by action. + $grouped = array( + 'created' => array(), + 'updated' => array(), + 'deleted' => array(), + 'failed' => array(), + ); + + foreach ( $sites as $site ) { + $action = $site['action'] ?? 'unknown'; + if ( isset( $grouped[ $action ] ) ) { + $grouped[ $action ][] = $site; + } + } + + $parts = array(); + + // Created on sites. + if ( ! empty( $grouped['created'] ) ) { + $links = array(); + foreach ( $grouped['created'] as $site ) { + $links[] = $this->render_site_link( $site ); + } + $parts[] = '' . esc_html__( 'Created on:', 'push-syndication' ) . ' ' . implode( ', ', $links ); + } + + // Updated on sites. + if ( ! empty( $grouped['updated'] ) ) { + $links = array(); + foreach ( $grouped['updated'] as $site ) { + $links[] = $this->render_site_link( $site ); + } + $parts[] = '' . esc_html__( 'Updated on:', 'push-syndication' ) . ' ' . implode( ', ', $links ); + } + + // Deleted from sites. + if ( ! empty( $grouped['deleted'] ) ) { + $links = array(); + foreach ( $grouped['deleted'] as $site ) { + $links[] = $this->render_site_link( $site, false ); // No remote link for deleted. + } + $parts[] = '' . esc_html__( 'Deleted from:', 'push-syndication' ) . ' ' . implode( ', ', $links ); + } + + // Failed sites. + if ( ! empty( $grouped['failed'] ) ) { + $links = array(); + foreach ( $grouped['failed'] as $site ) { + $error = $site['error'] ?? __( 'Unknown error', 'push-syndication' ); + $links[] = '' . + esc_html( $site['site_name'] ) . + ' (' . esc_html( $error ) . ')'; + } + $parts[] = '' . esc_html__( 'Failed:', 'push-syndication' ) . ' ' . implode( ', ', $links ); + } + + return implode( '
', $parts ); + } + + /** + * Render a site link with remote post ID. + * + * @param array $site The site data. + * @param bool $include_remote_link Whether to include a link to the remote post. + * @return string HTML for the link. + */ + private function render_site_link( array $site, bool $include_remote_link = true ): string { + $site_id = $site['site_id'] ?? 0; + $site_name = $site['site_name'] ?? __( 'Unknown site', 'push-syndication' ); + $remote_id = $site['remote_id'] ?? 0; + + // Site edit link. + $site_edit_url = admin_url( 'post.php?post=' . $site_id . '&action=edit' ); + $output = '' . esc_html( $site_name ) . ''; + + // Remote post link (to front-end, not admin - user may not have access). + if ( $include_remote_link && $remote_id > 0 ) { + $site_url = get_post_meta( $site_id, 'syn_site_url', true ); + if ( $site_url ) { + $remote_url = trailingslashit( $site_url ) . '?p=' . $remote_id; + $output .= ' '; + $output .= ''; + $output .= '#' . $remote_id . ' '; + } else { + $output .= ' → #' . $remote_id . ''; + } + } + + return $output; + } +} diff --git a/includes/Infrastructure/Logging/SyndicationLog.php b/includes/Infrastructure/Logging/SyndicationLog.php new file mode 100644 index 0000000..10109e4 --- /dev/null +++ b/includes/Infrastructure/Logging/SyndicationLog.php @@ -0,0 +1,558 @@ +|null + */ + private ?array $current_pull_entry = null; + + /** + * Current push log entry being built. + * + * @var array|null + */ + private ?array $current_push_entry = null; + + /** + * Site ID for current pull operation. + * + * @var int|null + */ + private ?int $current_site_id = null; + + /** + * Singleton instance. + * + * @var self|null + */ + private static ?self $instance = null; + + /** + * Get singleton instance. + * + * @return self + */ + public static function instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Start a new pull sync run. + * + * @param int $site_id The site ID being pulled from. + * @param string $site_name The site name. + */ + public function start_pull( int $site_id, string $site_name ): void { + $this->current_site_id = $site_id; + $this->current_pull_entry = array( + 'time' => current_time( 'mysql' ), + 'timestamp' => time(), + 'site_id' => $site_id, + 'site_name' => $site_name, + 'status' => self::STATUS_SUCCESS, + 'summary' => array( + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'failed' => 0, + ), + 'posts' => array(), + ); + } + + /** + * Log a pulled post result. + * + * @param string $action The action taken (created, updated, skipped, failed). + * @param int $local_id Local post ID (0 if failed). + * @param int $remote_id Remote post ID. + * @param string $title Post title. + * @param string|null $error Error message if failed. + */ + public function log_pulled_post( + string $action, + int $local_id, + int $remote_id, + string $title, + ?string $error = null + ): void { + if ( null === $this->current_pull_entry ) { + return; + } + + $this->current_pull_entry['posts'][] = array( + 'action' => $action, + 'local_id' => $local_id, + 'remote_id' => $remote_id, + 'title' => $title, + 'error' => $error, + ); + + // Update summary counts. + if ( isset( $this->current_pull_entry['summary'][ $action ] ) ) { + ++$this->current_pull_entry['summary'][ $action ]; + } + + // Update overall status based on results. + if ( self::ACTION_FAILED === $action ) { + if ( self::STATUS_SUCCESS === $this->current_pull_entry['status'] ) { + $this->current_pull_entry['status'] = self::STATUS_PARTIAL; + } + } + } + + /** + * End the current pull sync run and save the log. + * + * @param string|null $error Overall error message if the entire pull failed. + */ + public function end_pull( ?string $error = null ): void { + if ( null === $this->current_pull_entry || null === $this->current_site_id ) { + return; + } + + // If there's an overall error, mark as error status. + if ( null !== $error ) { + $this->current_pull_entry['status'] = self::STATUS_ERROR; + $this->current_pull_entry['error'] = $error; + } + + // If all posts failed, mark as error. + $summary = $this->current_pull_entry['summary']; + if ( $summary['failed'] > 0 && 0 === $summary['created'] && 0 === $summary['updated'] ) { + $this->current_pull_entry['status'] = self::STATUS_ERROR; + } + + // If no posts were processed at all, mark as skipped. + $total = $summary['created'] + $summary['updated'] + $summary['skipped'] + $summary['failed']; + if ( 0 === $total && null === $error ) { + $this->current_pull_entry['status'] = self::STATUS_SKIPPED; + } + + $this->save_pull_log( $this->current_site_id, $this->current_pull_entry ); + + $this->current_pull_entry = null; + $this->current_site_id = null; + } + + /** + * Start a new push event. + * + * @param int $post_id Local post ID. + * @param string $post_title Local post title. + */ + public function start_push( int $post_id, string $post_title ): void { + $this->current_push_entry = array( + 'time' => current_time( 'mysql' ), + 'timestamp' => time(), + 'post_id' => $post_id, + 'post_title' => $post_title, + 'status' => self::STATUS_SUCCESS, + 'sites' => array(), + ); + } + + /** + * Log a push to a site. + * + * @param int $site_id Target site ID. + * @param string $site_name Target site name. + * @param string $action The action taken (created, updated, deleted, failed). + * @param int $remote_id Remote post ID (0 if failed). + * @param string|null $error Error message if failed. + */ + public function log_pushed_site( + int $site_id, + string $site_name, + string $action, + int $remote_id, + ?string $error = null + ): void { + if ( null === $this->current_push_entry ) { + return; + } + + $this->current_push_entry['sites'][] = array( + 'site_id' => $site_id, + 'site_name' => $site_name, + 'action' => $action, + 'remote_id' => $remote_id, + 'error' => $error, + ); + + // Update overall status based on results. + if ( self::ACTION_FAILED === $action ) { + if ( self::STATUS_SUCCESS === $this->current_push_entry['status'] ) { + $this->current_push_entry['status'] = self::STATUS_PARTIAL; + } + } + } + + /** + * End the current push event and save the log. + */ + public function end_push(): void { + if ( null === $this->current_push_entry ) { + return; + } + + // Check if all sites failed. + $failed_count = 0; + $success_count = 0; + foreach ( $this->current_push_entry['sites'] as $site ) { + if ( self::ACTION_FAILED === $site['action'] ) { + ++$failed_count; + } else { + ++$success_count; + } + } + + if ( $failed_count > 0 && 0 === $success_count ) { + $this->current_push_entry['status'] = self::STATUS_ERROR; + } + + $this->save_push_log( $this->current_push_entry ); + + $this->current_push_entry = null; + } + + /** + * Save a pull log entry. + * + * @param int $site_id The site ID. + * @param array $entry The log entry. + */ + private function save_pull_log( int $site_id, array $entry ): void { + $log = get_post_meta( $site_id, self::PULL_LOG_META_KEY, true ); + + if ( ! is_array( $log ) ) { + $log = array(); + } + + // Get per-site limit or use default. + $limit = $this->get_site_log_limit( $site_id ); + + // Trim old entries if over limit. + if ( count( $log ) >= $limit ) { + $log = array_slice( $log, -( $limit - 1 ) ); + } + + $log[] = $entry; + + update_post_meta( $site_id, self::PULL_LOG_META_KEY, $log ); + } + + /** + * Get the log limit for a site. + * + * @param int $site_id The site ID. + * @return int The log entry limit. + */ + private function get_site_log_limit( int $site_id ): int { + $limit = get_post_meta( $site_id, self::LOG_LIMIT_META_KEY, true ); + + if ( '' === $limit || ! is_numeric( $limit ) ) { + $limit = self::DEFAULT_LOG_ENTRY_LIMIT; + } + + $limit = (int) $limit; + + // Ensure reasonable bounds (1-1000). + return max( 1, min( 1000, $limit ) ); + } + + /** + * Save a push log entry. + * + * @param array $entry The log entry. + */ + private function save_push_log( array $entry ): void { + $log = get_option( self::PUSH_LOG_OPTION_KEY, array() ); + + if ( ! is_array( $log ) ) { + $log = array(); + } + + /** + * Filter the push log entry limit. + * + * @param int $limit The maximum number of push log entries to keep. + */ + $limit = (int) apply_filters( 'syn_push_log_limit', self::DEFAULT_LOG_ENTRY_LIMIT ); + $limit = max( 1, min( 1000, $limit ) ); + + // Trim old entries if over limit. + if ( count( $log ) >= $limit ) { + $log = array_slice( $log, -( $limit - 1 ) ); + } + + $log[] = $entry; + + update_option( self::PUSH_LOG_OPTION_KEY, $log, false ); + } + + /** + * Get pull logs for a site or all sites. + * + * @param int|null $site_id Site ID to filter by, or null for all. + * @param int|null $timestamp Filter to a specific timestamp. + * @param string|null $status Filter by status. + * @param string|null $search Search term for post titles. + * @return array> + */ + public function get_pull_logs( + ?int $site_id = null, + ?int $timestamp = null, + ?string $status = null, + ?string $search = null + ): array { + $all_logs = array(); + + if ( null !== $site_id ) { + $site_logs = get_post_meta( $site_id, self::PULL_LOG_META_KEY, true ); + if ( is_array( $site_logs ) ) { + $all_logs = $site_logs; + } + } else { + // Get logs from all sites. + $sites = get_posts( + array( + 'post_type' => 'syn_site', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'fields' => 'ids', + ) + ); + + foreach ( $sites as $id ) { + $site_logs = get_post_meta( $id, self::PULL_LOG_META_KEY, true ); + if ( is_array( $site_logs ) ) { + $all_logs = array_merge( $all_logs, $site_logs ); + } + } + } + + // Apply filters. + $all_logs = array_filter( + $all_logs, + function ( $entry ) use ( $timestamp, $status, $search ) { + // Filter by timestamp. + if ( null !== $timestamp && ( $entry['timestamp'] ?? 0 ) !== $timestamp ) { + return false; + } + + // Filter by status. + if ( null !== $status && ( $entry['status'] ?? '' ) !== $status ) { + return false; + } + + // Filter by search term in post titles. + if ( null !== $search && '' !== $search ) { + $found = false; + foreach ( $entry['posts'] ?? array() as $post ) { + if ( stripos( $post['title'] ?? '', $search ) !== false ) { + $found = true; + break; + } + } + if ( ! $found ) { + return false; + } + } + + return true; + } + ); + + // Sort by timestamp descending (newest first). + usort( + $all_logs, + function ( $a, $b ) { + return ( $b['timestamp'] ?? 0 ) - ( $a['timestamp'] ?? 0 ); + } + ); + + return $all_logs; + } + + /** + * Get push logs. + * + * @param int|null $post_id Filter by local post ID. + * @param int|null $timestamp Filter to a specific timestamp. + * @param string|null $status Filter by status. + * @param string|null $search Search term. + * @return array> + */ + public function get_push_logs( + ?int $post_id = null, + ?int $timestamp = null, + ?string $status = null, + ?string $search = null + ): array { + $all_logs = get_option( self::PUSH_LOG_OPTION_KEY, array() ); + + if ( ! is_array( $all_logs ) ) { + $all_logs = array(); + } + + // Apply filters. + $all_logs = array_filter( + $all_logs, + function ( $entry ) use ( $post_id, $timestamp, $status, $search ) { + // Filter by post ID. + if ( null !== $post_id && ( $entry['post_id'] ?? 0 ) !== $post_id ) { + return false; + } + + // Filter by timestamp. + if ( null !== $timestamp && ( $entry['timestamp'] ?? 0 ) !== $timestamp ) { + return false; + } + + // Filter by status. + if ( null !== $status && ( $entry['status'] ?? '' ) !== $status ) { + return false; + } + + // Filter by search term. + if ( null !== $search && '' !== $search ) { + $found = false; + // Search in post title. + if ( stripos( $entry['post_title'] ?? '', $search ) !== false ) { + $found = true; + } + // Search in site names. + if ( ! $found ) { + foreach ( $entry['sites'] ?? array() as $site ) { + if ( stripos( $site['site_name'] ?? '', $search ) !== false ) { + $found = true; + break; + } + } + } + if ( ! $found ) { + return false; + } + } + + return true; + } + ); + + // Sort by timestamp descending (newest first). + usort( + $all_logs, + function ( $a, $b ) { + return ( $b['timestamp'] ?? 0 ) - ( $a['timestamp'] ?? 0 ); + } + ); + + return $all_logs; + } + + /** + * Get all unique sites that have pull logs. + * + * @return array + */ + public function get_sites_with_logs(): array { + $sites = get_posts( + array( + 'post_type' => 'syn_site', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'meta_query' => array( + array( + 'key' => self::PULL_LOG_META_KEY, + 'compare' => 'EXISTS', + ), + ), + ) + ); + + $result = array(); + foreach ( $sites as $site ) { + $result[] = array( + 'id' => $site->ID, + 'name' => $site->post_title, + ); + } + + return $result; + } + + /** + * Clear all logs for a site. + * + * @param int $site_id The site ID. + */ + public function clear_pull_logs( int $site_id ): void { + delete_post_meta( $site_id, self::PULL_LOG_META_KEY ); + } + + /** + * Clear all push logs. + */ + public function clear_push_logs(): void { + delete_option( self::PUSH_LOG_OPTION_KEY ); + } +} diff --git a/includes/class-syndication-logger-viewer.php b/includes/class-syndication-logger-viewer.php index 32333a0..cec8cdf 100644 --- a/includes/class-syndication-logger-viewer.php +++ b/includes/class-syndication-logger-viewer.php @@ -384,18 +384,50 @@ class Syndication_Logger_Viewer { * Registers admin menu and screen option hooks. */ public function __construct() { - add_action( 'admin_menu', array( $this, 'add_menu_items' ) ); + // Use priority 20 to appear after Pull Logs and Push Logs (priority 10). + add_action( 'admin_menu', array( $this, 'add_menu_items' ), 20 ); add_filter( 'set-screen-option', array( $this, 'set_screen_option' ), 10, 3 ); } /** - * Register the Logs submenu page under Syndication Sites. + * Register the Legacy Logs submenu page under Syndication Sites. + * + * Only shown when legacy logs exist. */ public function add_menu_items() { - $hook = add_submenu_page( 'edit.php?post_type=syn_site', 'Logs', 'Logs', 'activate_plugins', 'syndication_dashboard', array( $this, 'render_list_page' ) ); + // Only show legacy logs page if there are legacy logs. + if ( ! $this->has_legacy_logs() ) { + return; + } + + $hook = add_submenu_page( + 'edit.php?post_type=syn_site', + __( 'Legacy Logs', 'push-syndication' ), + __( 'Legacy Logs', 'push-syndication' ), + 'activate_plugins', + 'syndication_dashboard', + array( $this, 'render_list_page' ) + ); add_action( "load-$hook", array( $this, 'initialize_list_table' ) ); } + /** + * Check if there are any legacy logs in the database. + * + * @return bool True if legacy logs exist. + */ + private function has_legacy_logs() { + global $wpdb; + + // Check if any posts have the legacy syn_log meta key. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time check for legacy data. + $count = $wpdb->get_var( + "SELECT COUNT(*) FROM $wpdb->postmeta WHERE meta_key = 'syn_log' LIMIT 1" + ); + + return (int) $count > 0; + } + /** * Save the screen option value when submitted. * @@ -440,16 +472,29 @@ public function initialize_list_table() { */ public function render_list_page() { ?> -

- syndication_logger_table->prepare_items(); - ?> +
+

+ +
+

+ ' . esc_html__( 'Pull Logs', 'push-syndication' ) . '', + '' . esc_html__( 'Push Logs', 'push-syndication' ) . '' + ); + ?> +

+
+ + syndication_logger_table->prepare_items(); ?> +
syndication_logger_table->search_box( 'search', 'search_id' ); - $this->syndication_logger_table->display(); ?>
diff --git a/includes/class-wp-push-syndication-server.php b/includes/class-wp-push-syndication-server.php index d7adad1..cd19d2b 100644 --- a/includes/class-wp-push-syndication-server.php +++ b/includes/class-wp-push-syndication-server.php @@ -32,6 +32,16 @@ public function __construct() { // custom columns. add_filter( 'manage_edit-syn_site_columns', array( $this, 'add_new_columns' ) ); add_action( 'manage_syn_site_posts_custom_column', array( $this, 'manage_columns' ), 10, 2 ); + add_filter( 'manage_edit-syn_site_sortable_columns', array( $this, 'add_sortable_columns' ) ); + add_action( 'pre_get_posts', array( $this, 'handle_column_sorting' ) ); + + // bulk actions. + add_filter( 'bulk_actions-edit-syn_site', array( $this, 'add_bulk_actions' ) ); + add_filter( 'handle_bulk_actions-edit-syn_site', array( $this, 'handle_bulk_actions' ), 10, 3 ); + add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) ); + + // rename Published filter to Enabled. + add_filter( 'views_edit-syn_site', array( $this, 'rename_views' ) ); // submenus. add_action( 'admin_menu', array( $this, 'register_syndicate_settings' ) ); @@ -68,6 +78,8 @@ public function __construct() { // for many sites). add_action( 'syn_refresh_pull_jobs', array( $this, 'refresh_pull_jobs' ) ); + // AJAX handler for testing credentials. + add_action( 'wp_ajax_syn_test_credentials', array( $this, 'ajax_test_credentials' ) ); $this->register_syndicate_actions(); @@ -100,23 +112,27 @@ public function init() { 'syn_site', array( 'labels' => array( - 'name' => __( 'Sites' ), - 'singular_name' => __( 'Site' ), - 'add_new' => __( 'Add Site' ), - 'add_new_item' => __( 'Add New Site' ), - 'edit_item' => __( 'Edit Site' ), - 'new_item' => __( 'New Site' ), - 'view_item' => __( 'View Site' ), - 'search_items' => __( 'Search Sites' ), + 'name' => __( 'Sites', 'push-syndication' ), + 'singular_name' => __( 'Site', 'push-syndication' ), + 'add_new' => __( 'Add Site', 'push-syndication' ), + 'add_new_item' => __( 'Add New Site', 'push-syndication' ), + 'edit_item' => __( 'Edit Site', 'push-syndication' ), + 'new_item' => __( 'New Site', 'push-syndication' ), + 'view_item' => __( 'View Site', 'push-syndication' ), + 'search_items' => __( 'Search Sites', 'push-syndication' ), + 'not_found' => __( 'No sites found', 'push-syndication' ), + 'not_found_in_trash' => __( 'No sites found in Trash', 'push-syndication' ), + 'all_items' => __( 'Sites', 'push-syndication' ), + 'menu_name' => __( 'Syndication', 'push-syndication' ), ), - 'description' => __( 'Sites in the network' ), + 'description' => __( 'Syndication target sites', 'push-syndication' ), 'public' => false, 'show_ui' => true, 'publicly_queryable' => false, 'exclude_from_search' => true, - 'menu_position' => 100, - // @TODO we need a menu icon here. - 'hierarchical' => false, // @TODO check this + 'menu_position' => 80, + 'menu_icon' => 'dashicons-rss', + 'hierarchical' => false, 'query_var' => false, 'rewrite' => false, 'supports' => array( 'title' ), @@ -131,18 +147,17 @@ public function init() { 'syn_site', array( 'labels' => array( - 'name' => __( 'Site Groups' ), - 'singular_name' => __( 'Site Group' ), - 'search_items' => __( 'Search Site Groups' ), - 'popular_items' => __( 'Popular Site Groups' ), - 'all_items' => __( 'All Site Groups' ), - 'parent_item' => __( 'Parent Site Group' ), - 'parent_item_colon' => __( 'Parent Site Group' ), - 'edit_item' => __( 'Edit Site Group' ), - 'update_item' => __( 'Update Site Group' ), - 'add_new_item' => __( 'Add New Site Group' ), - 'new_item_name' => __( 'New Site Group Name' ), - + 'name' => __( 'Site Groups', 'push-syndication' ), + 'singular_name' => __( 'Site Group', 'push-syndication' ), + 'search_items' => __( 'Search Site Groups', 'push-syndication' ), + 'popular_items' => __( 'Popular Site Groups', 'push-syndication' ), + 'all_items' => __( 'All Site Groups', 'push-syndication' ), + 'parent_item' => __( 'Parent Site Group', 'push-syndication' ), + 'parent_item_colon' => __( 'Parent Site Group:', 'push-syndication' ), + 'edit_item' => __( 'Edit Site Group', 'push-syndication' ), + 'update_item' => __( 'Update Site Group', 'push-syndication' ), + 'add_new_item' => __( 'Add New Site Group', 'push-syndication' ), + 'new_item_name' => __( 'New Site Group Name', 'push-syndication' ), ), 'public' => false, 'show_ui' => true, @@ -155,13 +170,16 @@ public function init() { ); $this->push_syndicate_default_settings = array( - 'selected_pull_sitegroups' => array(), - 'selected_post_types' => array( 'post' ), - 'delete_pushed_posts' => 'off', - 'pull_time_interval' => '3600', - 'update_pulled_posts' => 'off', - 'client_id' => '', - 'client_secret' => '', + 'selected_pull_sitegroups' => array(), + 'selected_post_types' => array( 'post' ), + 'delete_pushed_posts' => 'off', + 'pull_time_interval' => '3600', + 'update_pulled_posts' => 'off', + 'notification_methods' => array(), + 'notification_email_address' => '', + 'notification_email_types' => array(), + 'notification_slack_webhook' => '', + 'notification_slack_types' => array(), ); $this->push_syndicate_settings = wp_parse_args( (array) get_option( 'push_syndicate_settings' ), $this->push_syndicate_default_settings ); @@ -183,23 +201,24 @@ public function register_syndicate_actions() { public function add_new_columns( $columns ) { $new_columns = array(); $new_columns['cb'] = ''; - $new_columns['title'] = _x( 'Site Name', 'column name' ); - $new_columns['client-type'] = _x( 'Client Type', 'column name' ); - $new_columns['syn_sitegroup'] = _x( 'Groups', 'column name' ); - $new_columns['site_status'] = _x( 'Status', 'column name' ); - $new_columns['date'] = _x( 'Date', 'column name' ); + $new_columns['title'] = _x( 'Site Name', 'column name', 'push-syndication' ); + $new_columns['transport'] = _x( 'Transport', 'column name', 'push-syndication' ); + $new_columns['syn_sitegroup'] = _x( 'Groups', 'column name', 'push-syndication' ); + $new_columns['site_status'] = _x( 'Status', 'column name', 'push-syndication' ); + $new_columns['date'] = _x( 'Date', 'column name', 'push-syndication' ); return $new_columns; } public function manage_columns( $column_name, $id ) { - global $wpdb; switch ( $column_name ) { - case 'client-type': + case 'transport': $transport_type = get_post_meta( $id, 'syn_transport_type', true ); + $transport_mode = get_post_meta( $id, 'syn_transport_mode', true ); + $transport_mode = ! empty( $transport_mode ) ? $transport_mode : 'push'; try { $client = Syndication_Client_Factory::get_client( $transport_type, $id ); $client_data = $client->get_client_data(); - echo esc_html( sprintf( '%s (%s)', $client_data['name'], array_shift( $client_data['modes'] ) ) ); + echo esc_html( sprintf( '%s (%s)', $client_data['name'], $transport_mode ) ); } catch ( Exception $e ) { printf( esc_html__( 'Unknown (%s)', 'push-syndication' ), esc_html( $transport_type ) ); } @@ -209,10 +228,12 @@ public function manage_columns( $column_name, $id ) { break; case 'site_status': $site_status = get_post_meta( $id, 'syn_site_enabled', true ); - if ( ! filter_var( $site_status, FILTER_VALIDATE_BOOLEAN ) ) { - esc_html_e( 'disabled', 'push-syndication' ); + if ( 'on' === $site_status ) { + echo ' '; + esc_html_e( 'Enabled', 'push-syndication' ); } else { - esc_html_e( 'enabled', 'push-syndication' ); + echo ' '; + esc_html_e( 'Disabled', 'push-syndication' ); } break; default: @@ -220,31 +241,193 @@ public function manage_columns( $column_name, $id ) { } } - public function admin_init() { - // @TODO define more parameters. - $name_match = '#class-syndication-(.+)-client\.php$#'; + /** + * Add sortable columns. + * + * @param array $columns Existing sortable columns. + * @return array Modified sortable columns. + */ + public function add_sortable_columns( array $columns ): array { + $columns['title'] = 'title'; + $columns['transport'] = 'transport'; + $columns['syn_sitegroup'] = 'syn_sitegroup'; + $columns['site_status'] = 'site_status'; + return $columns; + } - $full_path = __DIR__ . '/'; - if ( $handle = opendir( $full_path ) ) { - while ( false !== ( $entry = readdir( $handle ) ) ) { - if ( ! preg_match( $name_match, $entry, $matches ) ) { - continue; - } - require_once $full_path . $entry; - $class_name = 'Syndication_' . strtoupper( str_replace( '-', '_', $matches[1] ) ) . '_Client'; + /** + * Handle custom column sorting. + * + * @param \WP_Query $query The query object. + */ + public function handle_column_sorting( \WP_Query $query ): void { + if ( ! is_admin() || ! $query->is_main_query() ) { + return; + } - if ( ! class_exists( $class_name ) ) { - continue; - } - $client_data = call_user_func( array( $class_name, 'get_client_data' ) ); - if ( is_array( $client_data ) && ! empty( $client_data ) ) { - $this->push_syndicate_transports[ $client_data['id'] ] = array( - 'name' => $client_data['name'], - 'modes' => $client_data['modes'], - ); - } + if ( 'syn_site' !== $query->get( 'post_type' ) ) { + return; + } + + $orderby = $query->get( 'orderby' ); + + if ( 'transport' === $orderby ) { + $query->set( 'meta_key', 'syn_transport_type' ); + $query->set( 'orderby', 'meta_value' ); + } + + if ( 'syn_sitegroup' === $orderby ) { + add_filter( 'posts_clauses', array( $this, 'sort_by_taxonomy_clauses' ) ); + } + + if ( 'site_status' === $orderby ) { + $query->set( 'meta_key', 'syn_site_enabled' ); + $query->set( 'orderby', 'meta_value' ); + } + } + + /** + * Modify query clauses to sort by taxonomy term name. + * + * @param array $clauses Query clauses. + * @return array Modified clauses. + */ + public function sort_by_taxonomy_clauses( array $clauses ): array { + global $wpdb; + + $clauses['join'] .= " LEFT JOIN {$wpdb->term_relationships} AS tr ON ({$wpdb->posts}.ID = tr.object_id)"; + $clauses['join'] .= " LEFT JOIN {$wpdb->term_taxonomy} AS tt ON (tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'syn_sitegroup')"; + $clauses['join'] .= " LEFT JOIN {$wpdb->terms} AS t ON (tt.term_id = t.term_id)"; + $clauses['orderby'] = 't.name ' . ( 'ASC' === strtoupper( get_query_var( 'order' ) ) ? 'ASC' : 'DESC' ); + + // Remove filter after use to avoid affecting other queries. + remove_filter( 'posts_clauses', array( $this, 'sort_by_taxonomy_clauses' ) ); + + return $clauses; + } + + /** + * Add bulk actions for sites. + * + * @param array $actions Current bulk actions. + * @return array Modified bulk actions. + */ + public function add_bulk_actions( array $actions ): array { + unset( $actions['edit'] ); // Remove bulk edit (not useful for sites). + + $actions['enable_sites'] = __( 'Enable', 'push-syndication' ); + $actions['disable_sites'] = __( 'Disable', 'push-syndication' ); + + return $actions; + } + + /** + * Handle bulk actions for sites. + * + * @param string $sendback The redirect URL. + * @param string $doaction The action being performed. + * @param int[] $post_ids Array of post IDs. + * @return string Modified redirect URL. + */ + public function handle_bulk_actions( string $sendback, string $doaction, array $post_ids ): string { + if ( ! in_array( $doaction, array( 'enable_sites', 'disable_sites' ), true ) ) { + return $sendback; + } + + // Check capabilities. + if ( ! current_user_can( apply_filters( 'syn_syndicate_cap', 'manage_options' ) ) ) { + return $sendback; + } + + $updated = 0; + $status = 'enable_sites' === $doaction ? 'on' : 'off'; + + foreach ( $post_ids as $post_id ) { + update_post_meta( $post_id, 'syn_site_enabled', $status ); + ++$updated; + } + + // Remove previous notification parameters. + $sendback = remove_query_arg( array( 'bulk_sites_enabled', 'bulk_sites_disabled' ), $sendback ); + + $status_label = 'enable_sites' === $doaction ? 'enabled' : 'disabled'; + + return add_query_arg( 'bulk_sites_' . $status_label, $updated, $sendback ); + } + + /** + * Display admin notices for bulk actions. + */ + public function bulk_action_notices(): void { + $screen = get_current_screen(); + if ( ! $screen || 'edit-syn_site' !== $screen->id ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just displaying a notice. + if ( ! empty( $_GET['bulk_sites_enabled'] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $count = (int) $_GET['bulk_sites_enabled']; + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: number of sites */ + _n( '%d site enabled.', '%d sites enabled.', $count, 'push-syndication' ), + $count + ) + ) + ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just displaying a notice. + if ( ! empty( $_GET['bulk_sites_disabled'] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $count = (int) $_GET['bulk_sites_disabled']; + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: number of sites */ + _n( '%d site disabled.', '%d sites disabled.', $count, 'push-syndication' ), + $count + ) + ) + ); + } + } + + /** + * Rename "Published" to "Enabled" in views filter. + * + * @param array $views Current views. + * @return array Modified views. + */ + public function rename_views( array $views ): array { + if ( isset( $views['publish'] ) ) { + $views['publish'] = str_replace( + array( '>Published<', '>Published ' ), + array( '>Enabled<', '>Enabled ' ), + $views['publish'] + ); + } + return $views; + } + + public function admin_init() { + // Load available transports from the TransportFactory. + $container = \Automattic\Syndication\Infrastructure\DI\Container::instance(); + $factory = $container->get( \Automattic\Syndication\Domain\Contracts\TransportFactoryInterface::class ); + + if ( $factory instanceof \Automattic\Syndication\Domain\Contracts\TransportFactoryInterface ) { + foreach ( $factory->get_available_transports() as $id => $data ) { + $this->push_syndicate_transports[ $id ] = array( + 'name' => $data['name'], + 'modes' => $data['modes'], + ); } } + $this->push_syndicate_transports = apply_filters( 'syn_transports', $this->push_syndicate_transports ); // register settings. @@ -262,20 +445,44 @@ public function load_scripts_and_styles( $hook ) { wp_enqueue_style( 'syn-edit-sites', plugins_url( 'css/sites.css', __FILE__ ), array(), $this->version ); } elseif ( in_array( $hook, array( 'post.php', 'post-new.php' ) ) ) { wp_enqueue_style( 'syn-edit-site', plugins_url( 'css/edit-site.css', __FILE__ ), array(), $this->version ); + + // Enqueue credential testing script. + wp_enqueue_script( + 'syn-test-credentials', + plugins_url( 'js/test-credentials.js', __FILE__ ), + array( 'jquery' ), + $this->version, + true + ); + + wp_localize_script( + 'syn-test-credentials', + 'synTestCredentials', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'syn_test_credentials' ), + 'siteId' => get_the_ID() ?: 0, + 'testing' => __( 'Testing...', 'push-syndication' ), + 'test' => __( 'Test Credentials', 'push-syndication' ), + ) + ); } } } public function push_syndicate_settings_validate( $raw_settings ) { - $settings = array(); - $settings['client_id'] = sanitize_text_field( $raw_settings['client_id'] ); - $settings['client_secret'] = sanitize_text_field( $raw_settings['client_secret'] ); - $settings['selected_post_types'] = ! empty( $raw_settings['selected_post_types'] ) ? $raw_settings['selected_post_types'] : array(); - $settings['delete_pushed_posts'] = ! empty( $raw_settings['delete_pushed_posts'] ) ? $raw_settings['delete_pushed_posts'] : 'off'; - $settings['selected_pull_sitegroups'] = ! empty( $raw_settings['selected_pull_sitegroups'] ) ? $raw_settings['selected_pull_sitegroups'] : array(); - $settings['pull_time_interval'] = ! empty( $raw_settings['pull_time_interval'] ) ? max( $raw_settings['pull_time_interval'], 300 ) : '3600'; - $settings['update_pulled_posts'] = ! empty( $raw_settings['update_pulled_posts'] ) ? $raw_settings['update_pulled_posts'] : 'off'; + $settings = array(); + $settings['selected_post_types'] = ! empty( $raw_settings['selected_post_types'] ) ? array_map( 'sanitize_text_field', $raw_settings['selected_post_types'] ) : array(); + $settings['delete_pushed_posts'] = ! empty( $raw_settings['delete_pushed_posts'] ) ? sanitize_text_field( $raw_settings['delete_pushed_posts'] ) : 'off'; + $settings['selected_pull_sitegroups'] = ! empty( $raw_settings['selected_pull_sitegroups'] ) ? array_map( 'sanitize_text_field', $raw_settings['selected_pull_sitegroups'] ) : array(); + $settings['pull_time_interval'] = ! empty( $raw_settings['pull_time_interval'] ) ? max( (int) $raw_settings['pull_time_interval'], 300 ) : 3600; + $settings['update_pulled_posts'] = ! empty( $raw_settings['update_pulled_posts'] ) ? sanitize_text_field( $raw_settings['update_pulled_posts'] ) : 'off'; + $settings['notification_methods'] = ! empty( $raw_settings['notification_methods'] ) ? array_map( 'sanitize_text_field', $raw_settings['notification_methods'] ) : array(); + $settings['notification_email_address'] = ! empty( $raw_settings['notification_email_address'] ) ? sanitize_email( $raw_settings['notification_email_address'] ) : ''; + $settings['notification_email_types'] = ! empty( $raw_settings['notification_email_types'] ) ? array_map( 'sanitize_text_field', $raw_settings['notification_email_types'] ) : array(); + $settings['notification_slack_webhook'] = ! empty( $raw_settings['notification_slack_webhook'] ) ? esc_url_raw( $raw_settings['notification_slack_webhook'] ) : ''; + $settings['notification_slack_types'] = ! empty( $raw_settings['notification_slack_types'] ) ? array_map( 'sanitize_text_field', $raw_settings['notification_slack_types'] ) : array(); $this->pre_schedule_pull_content( $settings['selected_pull_sitegroups'] ); @@ -283,109 +490,195 @@ public function push_syndicate_settings_validate( $raw_settings ) { } public function register_syndicate_settings() { - add_submenu_page( 'options-general.php', esc_html__( 'Push Syndication Settings', 'push-syndication' ), esc_html__( 'Push Syndication', 'push-syndication' ), apply_filters( 'syn_syndicate_cap', 'manage_options' ), 'push-syndicate-settings', array( $this, 'display_syndicate_settings' ) ); + add_submenu_page( + 'edit.php?post_type=syn_site', + esc_html__( 'Syndication Settings', 'push-syndication' ), + esc_html__( 'Settings', 'push-syndication' ), + apply_filters( 'syn_syndicate_cap', 'manage_options' ), + 'syndication-settings', + array( $this, 'display_syndicate_settings' ) + ); } public function display_syndicate_settings() { - add_settings_section( 'push_syndicate_pull_sitegroups', esc_html__( 'Site Groups', 'push-syndication' ), array( $this, 'display_pull_sitegroups_description' ), 'push_syndicate_pull_sitegroups' ); - add_settings_field( 'pull_sitegroups_selection', esc_html__( 'select sitegroups', 'push-syndication' ), array( $this, 'display_pull_sitegroups_selection' ), 'push_syndicate_pull_sitegroups', 'push_syndicate_pull_sitegroups' ); + // Pull Settings sections. + add_settings_section( 'pull_sitegroups', '', '__return_false', 'syn_pull_settings' ); + add_settings_field( 'pull_sitegroups_selection', esc_html__( 'Site Groups', 'push-syndication' ), array( $this, 'display_pull_sitegroups_selection' ), 'syn_pull_settings', 'pull_sitegroups' ); - add_settings_section( 'push_syndicate_pull_options', esc_html__( 'Pull Options', 'push-syndication' ), array( $this, 'display_pull_options_description' ), 'push_syndicate_pull_options' ); - add_settings_field( 'pull_time_interval', esc_html__( 'Specify time interval in seconds', 'push-syndication' ), array( $this, 'display_time_interval_selection' ), 'push_syndicate_pull_options', 'push_syndicate_pull_options' ); - add_settings_field( 'max_pull_attempts', esc_html__( 'Maximum pull attempts', 'push-syndication' ), array( $this, 'display_max_pull_attempts' ), 'push_syndicate_pull_options', 'push_syndicate_pull_options' ); - add_settings_field( 'update_pulled_posts', esc_html__( 'update pulled posts', 'push-syndication' ), array( $this, 'display_update_pulled_posts_selection' ), 'push_syndicate_pull_options', 'push_syndicate_pull_options' ); + add_settings_section( 'pull_options', '', '__return_false', 'syn_pull_settings' ); + add_settings_field( 'pull_time_interval', esc_html__( 'Time interval', 'push-syndication' ), array( $this, 'display_time_interval_selection' ), 'syn_pull_settings', 'pull_options', array( 'label_for' => 'syn_pull_time_interval' ) ); + add_settings_field( 'max_pull_attempts', esc_html__( 'Maximum pull attempts', 'push-syndication' ), array( $this, 'display_max_pull_attempts' ), 'syn_pull_settings', 'pull_options', array( 'label_for' => 'syn_max_pull_attempts' ) ); + add_settings_field( 'update_pulled_posts', esc_html__( 'Update pulled posts', 'push-syndication' ), array( $this, 'display_update_pulled_posts_selection' ), 'syn_pull_settings', 'pull_options', array( 'label_for' => 'syn_update_pulled_posts' ) ); - add_settings_section( 'push_syndicate_post_types', esc_html__( 'Post Types', 'push-syndication' ), array( $this, 'display_push_post_types_description' ), 'push_syndicate_post_types' ); - add_settings_field( 'post_type_selection', esc_html__( 'select post types', 'push-syndication' ), array( $this, 'display_post_types_selection' ), 'push_syndicate_post_types', 'push_syndicate_post_types' ); + // Push Settings section. + add_settings_section( 'push_options', '', '__return_false', 'syn_push_settings' ); + add_settings_field( 'post_type_selection', esc_html__( 'Post types', 'push-syndication' ), array( $this, 'display_post_types_selection' ), 'syn_push_settings', 'push_options' ); + add_settings_field( 'delete_pushed_posts', esc_html__( 'Delete pushed posts', 'push-syndication' ), array( $this, 'display_delete_pushed_posts_selection' ), 'syn_push_settings', 'push_options', array( 'label_for' => 'syn_delete_pushed_posts' ) ); - add_settings_section( 'delete_pushed_posts', esc_html__( ' Delete Pushed Posts ', 'push-syndication' ), array( $this, 'display_delete_pushed_posts_description' ), 'delete_pushed_posts' ); - add_settings_field( 'delete_post_check', esc_html__( ' delete pushed posts ', 'push-syndication' ), array( $this, 'display_delete_pushed_posts_selection' ), 'delete_pushed_posts', 'delete_pushed_posts' ); - - add_settings_section( 'api_token', esc_html__( ' API Token Configuration ', 'push-syndication' ), array( $this, 'display_apitoken_description' ), 'api_token' ); - add_settings_field( 'client_id', esc_html__( ' Enter your client id ', 'push-syndication' ), array( $this, 'display_client_id' ), 'api_token', 'api_token' ); - add_settings_field( 'client_secret', esc_html__( ' Enter your client secret ', 'push-syndication' ), array( $this, 'display_client_secret' ), 'api_token', 'api_token' ); + // Notifications section. + add_settings_section( 'notification_options', '', '__return_false', 'syn_notifications' ); + add_settings_field( 'notification_email_enabled', esc_html__( 'Email', 'push-syndication' ), array( $this, 'display_notification_email_settings' ), 'syn_notifications', 'notification_options' ); + add_settings_field( 'notification_slack_enabled', esc_html__( 'Slack', 'push-syndication' ), array( $this, 'display_notification_slack_settings' ), 'syn_notifications', 'notification_options' ); ?> -
- - +
-

+

- +

+

+ + display_pull_cron_status(); ?> - + - +

+

- + - +

+

- +
- get_api_token(); ?> -
$cron ) { + if ( isset( $cron['syn_pull_content'] ) ) { + $next_scheduled = $timestamp; + break; + } + } + } + } + + echo '
'; + + if ( $next_scheduled ) { + $time_diff = $next_scheduled - time(); + + if ( $time_diff > 0 ) { + printf( + '

%s %s

', + esc_html__( 'Next scheduled pull:', 'push-syndication' ), + esc_html( human_time_diff( time(), $next_scheduled ) . ' ' . __( 'from now', 'push-syndication' ) ) + ); + } else { + printf( + '

%s %s

', + esc_html__( 'Next scheduled pull:', 'push-syndication' ), + esc_html__( 'Pending (overdue)', 'push-syndication' ) + ); + } + + printf( + '

%s %s

', + esc_html__( 'Scheduled for:', 'push-syndication' ), + esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next_scheduled ) ) + ); + } else { + printf( + '

%s %s

', + esc_html__( 'Status:', 'push-syndication' ), + esc_html__( 'No pull jobs scheduled. Select Site Groups and save to schedule pulls.', 'push-syndication' ) + ); + } + + echo '
'; } public function display_pull_sitegroups_selection() { - - // get all sitegroups. $sitegroups = get_terms( - 'syn_sitegroup', array( + 'taxonomy' => 'syn_sitegroup', 'fields' => 'all', 'hide_empty' => false, 'orderby' => 'name', - ) + ) ); - // if there are no sitegroups defined return. - if ( empty( $sitegroups ) ) { - echo '

' . esc_html__( 'No sitegroups defined yet. You must group your sites into sitegroups to syndicate content', 'push-syndication' ) . '

'; - echo '

' . esc_html__( 'Create new', 'push-syndication' ) . '

'; + // If there are no Site Groups defined, return. + if ( empty( $sitegroups ) || is_wp_error( $sitegroups ) ) { + echo '

' . esc_html__( 'No Site Groups defined yet.', 'push-syndication' ) . '

'; + echo '

' . esc_html__( 'Create a Site Group', 'push-syndication' ) . '

'; return; } - foreach ( $sitegroups as $sitegroup ) { + echo '
' . esc_html__( 'Site Groups', 'push-syndication' ) . ''; + foreach ( $sitegroups as $sitegroup ) { ?> -

- description ); ?> + description ) ) { + echo ' — ' . esc_html( $sitegroup->description ) . ''; + } + ?>

- '; + echo '

' . esc_html__( 'Select which Site Groups to pull content from on schedule.', 'push-syndication' ) . '

'; } public function display_time_interval_selection() { - echo ''; + $intervals = array( + 300 => __( '5 minutes', 'push-syndication' ), + 900 => __( '15 minutes', 'push-syndication' ), + 1800 => __( '30 minutes', 'push-syndication' ), + 3600 => __( '1 hour', 'push-syndication' ), + 7200 => __( '2 hours', 'push-syndication' ), + 21600 => __( '6 hours', 'push-syndication' ), + 43200 => __( '12 hours', 'push-syndication' ), + 86400 => __( '24 hours', 'push-syndication' ), + ); + + $current_value = (int) $this->push_syndicate_settings['pull_time_interval']; + + // Fall back to 1 hour if saved value doesn't match any option. + if ( ! array_key_exists( $current_value, $intervals ) ) { + $current_value = 3600; + } + ?> + + - -

+ +

push_syndicate_settings['update_pulled_posts'], 'on' ) . ' />'; - } - - public function display_push_post_types_description() { - echo esc_html__( 'Select the post types to add support for pushing content', 'push-syndication' ); + ?> + push_syndicate_settings['update_pulled_posts'], 'on' ); ?> /> +

+ true ), 'objects' ); - // @TODO add more suitable filters. - $post_types = get_post_types( array( 'public' => true ) ); - - echo '
    '; + echo '
    ' . esc_html__( 'Post types', 'push-syndication' ) . ''; foreach ( $post_types as $post_type ) { - ?> - -
  • +

    -

  • - +

    '; - } - - public function display_delete_pushed_posts_description() { - echo esc_html__( 'Tick the box to delete all the pushed posts when the master post is deleted', 'push-syndication' ); + echo '
    '; + echo '

    ' . esc_html__( 'Select which post types can be pushed to remote sites.', 'push-syndication' ) . '

    '; } public function display_delete_pushed_posts_selection() { - // @TODO refractor this. - echo 'push_syndicate_settings['delete_pushed_posts'], 'on' ) . ' />'; - } - - public function display_apitoken_description() { - // @TODO add client type information. - echo '

    ' . esc_html__( 'To syndicate content to WordPress.com you must ', 'push-syndication' ) . '' . esc_html__( 'create a new application', 'push-syndication' ) . '

    '; - echo '

    ' . esc_html__( 'Enter the Redirect URI as follows', 'push-syndication' ) . '

    '; - echo '

    ' . esc_html( menu_page_url( 'push-syndicate-settings', false ) ) . '

    '; - } - - public function display_client_id() { - echo ''; + ?> + push_syndicate_settings['delete_pushed_posts'], 'on' ); ?> /> +

    + push_syndicate_settings['client_secret'] ) . '"/>'; + /** + * Display email notification settings. + */ + public function display_notification_email_settings() { + $settings = $this->push_syndicate_settings; + $email_enabled = ! empty( $settings['notification_methods'] ) && in_array( 'email', (array) $settings['notification_methods'], true ); + $email_address = $settings['notification_email_address'] ?? ''; + $email_types = $settings['notification_email_types'] ?? array(); + ?> +

    + +

    +

    + + +

    +

    +

    +

    + +

    +

    + +

    +

    + +

    +

    + +

    + push_syndicate_settings['client_id'] . '&redirect_uri=' . $redirect_uri . '&response_type=code'; - - echo '

    ' . esc_html__( 'Authorization ', 'push-syndication' ) . '

    '; - - // if code is not found return or settings updated return. - if ( empty( $_GET['code'] ) || ! empty( $_GET['settings-updated'] ) ) { - echo '

    ' . esc_html__( 'Click the authorize button to generate api token', 'push-syndication' ) . '

    '; - - ?> - - - + /** + * Display Slack notification settings. + */ + public function display_notification_slack_settings() { + $settings = $this->push_syndicate_settings; + $slack_enabled = ! empty( $settings['notification_methods'] ) && in_array( 'slack', (array) $settings['notification_methods'], true ); + $slack_webhook = $settings['notification_slack_webhook'] ?? ''; + $slack_types = $settings['notification_slack_types'] ?? array(); + ?> +

    + +

    +

    + + +

    +

    false, - 'body' => array( - 'client_id' => $this->push_syndicate_settings['client_id'], - 'redirect_uri' => $redirect_uri, - 'client_secret' => $this->push_syndicate_settings['client_secret'], - 'code' => $_GET['code'], - 'grant_type' => 'authorization_code', - ), - ) - ); - - $result = json_decode( $response['body'] ); - - if ( ! empty( $result->error ) ) { - echo '

    ' . esc_html__( 'Error retrieving API token ', 'push-syndication' ) . esc_html( $result->error_description ) . esc_html__( 'Please authorize again', 'push-syndication' ) . '

    '; - + printf( + /* translators: %s: link to Slack webhooks setup page */ + esc_html__( 'Set up a new Slack webhook URL %s.', 'push-syndication' ), + '' . esc_html__( 'here', 'push-syndication' ) . '' + ); ?> - - - - - - - - - - - - - - - - - - - - -
    Access tokenaccess_token ); ?>
    Blog IDblog_id ); ?>
    Blog URLblog_url ); ?>
    - +

    +

    +

    + +

    +

    + +

    +

    + +

    +

    + +

    ' . esc_html__( 'Enter the above details in relevant fields when registering a ', 'push-syndication' ) . 'WordPress.com' . esc_html__( 'site', 'push-syndication' ) . '

    '; } public function display_sitegroups_selection() { @@ -594,46 +894,32 @@ public function display_sitegroups_selection() { public function site_metaboxes() { add_meta_box( 'sitediv', __( ' Site Settings ' ), array( $this, 'add_site_settings_metabox' ), 'syn_site', 'normal', 'high' ); + add_meta_box( 'syn_pull_settings', __( ' Pull Settings ', 'push-syndication' ), array( $this, 'add_pull_settings_metabox' ), 'syn_site', 'normal', 'default' ); remove_meta_box( 'submitdiv', 'syn_site', 'side' ); add_meta_box( 'submitdiv', __( ' Site Status ' ), array( $this, 'add_site_status_metabox' ), 'syn_site', 'side', 'high' ); } public function add_site_status_metabox( $site ) { $site_enabled = get_post_meta( $site->ID, 'syn_site_enabled', true ); + $site_enabled = ! empty( $site_enabled ) ? $site_enabled : 'off'; ?>
    - - - - - - - -
    - - - -
    - + + +
    + + +
    - -
    @@ -675,16 +961,75 @@ public function add_site_status_metabox( $site ) { ID, 'syn_pull_post_status', true ); + $log_limit = get_post_meta( $site->ID, 'syn_log_limit', true ); + + // Default values. + $pull_post_status = ! empty( $pull_post_status ) ? $pull_post_status : ''; + $log_limit = '' !== $log_limit ? (int) $log_limit : 100; + + ?> +

    + +

    + + + + + + + + + + + + ID, 'syn_transport_type', true ); + $transport_mode = get_post_meta( $post->ID, 'syn_transport_mode', true ); $site_enabled = get_post_meta( $post->ID, 'syn_site_enabled', true ); // default values. $transport_type = ! empty( $transport_type ) ? $transport_type : 'WP_XMLRPC'; - $transport_mode = ! empty( $transport_mode ) ? $transport_mode : 'pull'; + $transport_mode = ! empty( $transport_mode ) ? $transport_mode : 'push'; $site_enabled = ! empty( $site_enabled ) ? $site_enabled : 'off'; // nonce for verification when saving. @@ -700,23 +1045,62 @@ public function add_site_settings_metabox( $post ) { ?> +
    + + +
    +
    ' . esc_html__( 'Select a transport type', 'push-syndication' ) . '

    '; - // TODO: add direction. - echo ''; + + // Push optgroup. + if ( ! empty( $push_transports ) ) { + echo ''; + foreach ( $push_transports as $key => $name ) { + $value = $key . '|push'; + /* translators: %s: transport name */ + $label = sprintf( __( '%s (push)', 'push-syndication' ), $name ); + echo ''; + } + echo ''; + } + + // Pull optgroup. + if ( ! empty( $pull_transports ) ) { + echo ''; + foreach ( $pull_transports as $key => $name ) { + $value = $key . '|pull'; + /* translators: %s: transport name */ + $label = sprintf( __( '%s (pull)', 'push-syndication' ), $name ); + echo ''; + } + echo ''; } + echo ''; } @@ -734,10 +1118,25 @@ public function save_site_settings() { return; } - $transport_type = sanitize_text_field( $_POST['transport_type'] ); // TODO: validate this exists. + // Parse composite transport_type_mode value (e.g., "WP_REST_API|push"). + $transport_type_mode = isset( $_POST['transport_type_mode'] ) ? sanitize_text_field( $_POST['transport_type_mode'] ) : ''; + $parts = explode( '|', $transport_type_mode ); + $transport_type = $parts[0] ?? 'WP_XMLRPC'; + $transport_mode = $parts[1] ?? 'push'; + + // Validate transport type exists. + if ( ! isset( $this->push_syndicate_transports[ $transport_type ] ) ) { + $transport_type = 'WP_XMLRPC'; + } + + // Validate mode is valid for this transport. + $valid_modes = $this->push_syndicate_transports[ $transport_type ]['modes'] ?? array( 'push' ); + if ( ! in_array( $transport_mode, $valid_modes, true ) ) { + $transport_mode = $valid_modes[0] ?? 'push'; + } - // @TODO validate that type and mode are valid. update_post_meta( $post->ID, 'syn_transport_type', $transport_type ); + update_post_meta( $post->ID, 'syn_transport_mode', $transport_mode ); $site_enabled = sanitize_text_field( $_POST['site_enabled'] ); @@ -774,6 +1173,205 @@ function ( $location ) { } update_post_meta( $post->ID, 'syn_site_enabled', $site_enabled ); + + // Save pull settings. + if ( isset( $_POST['syn_pull_post_status'] ) ) { + $pull_post_status = sanitize_text_field( $_POST['syn_pull_post_status'] ); + // Only allow valid statuses. + if ( in_array( $pull_post_status, array( '', 'draft', 'pending', 'publish' ), true ) ) { + update_post_meta( $post->ID, 'syn_pull_post_status', $pull_post_status ); + } + } + + if ( isset( $_POST['syn_log_limit'] ) ) { + $log_limit = (int) $_POST['syn_log_limit']; + // Clamp to valid range (1-1000). + $log_limit = max( 1, min( 1000, $log_limit ) ); + update_post_meta( $post->ID, 'syn_log_limit', $log_limit ); + } + } + + /** + * AJAX handler for testing site credentials. + * + * Tests the connection without saving, providing immediate feedback. + * For existing sites, falls back to stored credentials if form fields are empty. + */ + public function ajax_test_credentials() { + // Verify nonce. + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'syn_test_credentials' ) ) { + wp_send_json_error( array( 'message' => __( 'Security check failed.', 'push-syndication' ) ) ); + } + + // Check capabilities. + if ( ! current_user_can( apply_filters( 'syn_syndicate_cap', 'manage_options' ) ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'push-syndication' ) ) ); + } + + // Get and validate transport type. + $transport_type_mode = isset( $_POST['transport_type_mode'] ) ? sanitize_text_field( $_POST['transport_type_mode'] ) : ''; + $parts = explode( '|', $transport_type_mode ); + $transport_type = $parts[0] ?? ''; + + if ( empty( $transport_type ) || ! isset( $this->push_syndicate_transports[ $transport_type ] ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid transport type.', 'push-syndication' ) ) ); + } + + // Get site ID for existing sites (to fall back to stored credentials). + $site_id = isset( $_POST['site_id'] ) ? absint( $_POST['site_id'] ) : 0; + + // Merge form data with stored credentials for empty fields. + $credentials = $this->get_test_credentials( $transport_type, $_POST, $site_id ); + + // Build transport instance based on type. + $transport = $this->create_transport_for_testing( $transport_type, $credentials ); + + if ( null === $transport ) { + wp_send_json_error( array( 'message' => __( 'Could not create transport. Please check all fields are filled.', 'push-syndication' ) ) ); + } + + // Test the connection. + try { + $result = $transport->test_connection(); + + if ( $result ) { + wp_send_json_success( array( 'message' => __( 'Connection successful! Credentials are valid.', 'push-syndication' ) ) ); + } else { + wp_send_json_error( array( 'message' => __( 'Connection failed. Please check your credentials.', 'push-syndication' ) ) ); + } + } catch ( Exception $e ) { + wp_send_json_error( array( 'message' => sprintf( __( 'Connection error: %s', 'push-syndication' ), $e->getMessage() ) ) ); + } + } + + /** + * Get credentials for testing, merging form data with stored values. + * + * @param string $transport_type The transport type. + * @param array $post_data The POST data from the form. + * @param int $site_id The site post ID (0 for new sites). + * @return array Merged credentials. + */ + private function get_test_credentials( string $transport_type, array $post_data, int $site_id ): array { + $credentials = $post_data; + + // If no site ID, return form data as-is. + if ( $site_id <= 0 ) { + return $credentials; + } + + // Get stored values for empty form fields. + $container = \Automattic\Syndication\Infrastructure\DI\Container::instance(); + $encryptor = $container->get( \Automattic\Syndication\Domain\Contracts\EncryptorInterface::class ); + + // Site URL - use stored if form is empty. + if ( empty( $credentials['site_url'] ) ) { + $credentials['site_url'] = get_post_meta( $site_id, 'syn_site_url', true ); + } + + // Username - use stored if form is empty. + if ( empty( $credentials['site_username'] ) ) { + $credentials['site_username'] = get_post_meta( $site_id, 'syn_site_username', true ); + } + + // Password - use stored (decrypted) if form is empty. + if ( empty( $credentials['site_password'] ) ) { + $encrypted = get_post_meta( $site_id, 'syn_site_password', true ); + if ( ! empty( $encrypted ) && $encryptor instanceof \Automattic\Syndication\Domain\Contracts\EncryptorInterface ) { + $decrypted = $encryptor->decrypt( $encrypted ); + $credentials['site_password'] = is_string( $decrypted ) ? $decrypted : ''; + } + } + + // Token (for WordPress.com) - use stored (decrypted) if form is empty. + if ( empty( $credentials['site_token'] ) ) { + $encrypted = get_post_meta( $site_id, 'syn_site_token', true ); + if ( ! empty( $encrypted ) && $encryptor instanceof \Automattic\Syndication\Domain\Contracts\EncryptorInterface ) { + $decrypted = $encryptor->decrypt( $encrypted ); + $credentials['site_token'] = is_string( $decrypted ) ? $decrypted : ''; + } + } + + // Blog ID (for WordPress.com) - use stored if form is empty. + if ( empty( $credentials['blog_id'] ) ) { + $credentials['blog_id'] = get_post_meta( $site_id, 'syn_site_id', true ); + } + + // Feed URL (for RSS) - use stored if form is empty. + if ( empty( $credentials['feed_url'] ) ) { + $credentials['feed_url'] = get_post_meta( $site_id, 'syn_feed_url', true ); + } + + return $credentials; + } + + /** + * Create a transport instance for credential testing. + * + * @param string $transport_type The transport type ID. + * @param array $post_data The POST data with credentials. + * @return \Automattic\Syndication\Domain\Contracts\TransportInterface|null + */ + private function create_transport_for_testing( string $transport_type, array $post_data ) { + $site_url = isset( $post_data['site_url'] ) ? esc_url_raw( $post_data['site_url'] ) : ''; + $username = isset( $post_data['site_username'] ) ? sanitize_text_field( $post_data['site_username'] ) : ''; + $password = isset( $post_data['site_password'] ) ? $post_data['site_password'] : ''; + + switch ( $transport_type ) { + case 'WP_REST_API': + if ( empty( $site_url ) || empty( $username ) || empty( $password ) ) { + return null; + } + return new \Automattic\Syndication\Infrastructure\Transport\REST\WordPressRestTransport( + 0, // No site ID for testing. + $site_url, + $username, + $password + ); + + case 'WP_XMLRPC': + if ( empty( $site_url ) || empty( $username ) || empty( $password ) ) { + return null; + } + return new \Automattic\Syndication\Infrastructure\Transport\XMLRPC\XMLRPCTransport( + 0, + $site_url, + $username, + $password + ); + + case 'WP_REST': + // WordPress.com REST requires token and blog_id. + $token = isset( $post_data['site_token'] ) ? $post_data['site_token'] : $password; + $blog_id = isset( $post_data['site_id'] ) ? sanitize_text_field( $post_data['site_id'] ) : ''; + if ( empty( $token ) || empty( $blog_id ) ) { + return null; + } + return new \Automattic\Syndication\Infrastructure\Transport\REST\WordPressComTransport( + 0, + $token, + $blog_id + ); + + case 'WP_RSS': + // RSS doesn't need authentication testing in the same way. + $feed_url = isset( $post_data['feed_url'] ) ? esc_url_raw( $post_data['feed_url'] ) : $site_url; + if ( empty( $feed_url ) ) { + return null; + } + return new \Automattic\Syndication\Infrastructure\Transport\Feed\RSSFeedTransport( + 0, + $feed_url, + 'post', + 'draft', + 'closed', + 'closed', + false + ); + + default: + return null; + } } public function push_syndicate_admin_messages( $messages ) { diff --git a/push-syndication.php b/push-syndication.php index 89cf053..2f44cfa 100644 --- a/push-syndication.php +++ b/push-syndication.php @@ -16,31 +16,45 @@ * Text Domain: push-syndication */ -define( 'SYNDICATION_VERSION', '3.0.0-alpha' ); +declare( strict_types=1 ); -// Load PSR-4 autoloader for namespaced classes. -require_once __DIR__ . '/includes/Autoloader.php'; -Syndication_Autoloader::register( __DIR__ ); +// Prevent direct access. +defined( 'ABSPATH' ) || exit; + +// Define plugin constants. +define( 'SYNDICATION_FILE', __FILE__ ); +define( 'SYNDICATION_VERSION', '3.0.0-alpha' ); +// Encryption key constant (define in wp-config.php for production). if ( ! defined( 'PUSH_SYNDICATE_KEY' ) ) { define( 'PUSH_SYNDICATE_KEY', 'PUSH_SYNDICATE_KEY' ); } -// Initialise the new DDD architecture. -use Automattic\Syndication\Application\Bootstrapper; +// Load PSR-4 autoloader for namespaced classes. +require_once __DIR__ . '/includes/Autoloader.php'; +Syndication_Autoloader::register( __DIR__ ); -$GLOBALS['syndication_bootstrapper'] = Bootstrapper::init(); +// Initialise plugin via bootstrapper. +add_action( + 'plugins_loaded', + static function (): void { + $container = \Automattic\Syndication\Infrastructure\DI\Container::instance(); + $bootstrapper = new \Automattic\Syndication\Infrastructure\WordPress\PluginBootstrapper( $container ); + $bootstrapper->init(); + }, + 10 +); /** - * Get the Syndication DI container. + * Get the Syndication DI container instance. * - * Helper function for accessing the dependency injection container - * from legacy code during the migration to the new architecture. + * This function is provided for third-party developers who need to access + * plugin services. Internal plugin code should use Container::instance() directly. * * @return \Automattic\Syndication\Infrastructure\DI\Container The container. */ function syndication_container(): \Automattic\Syndication\Infrastructure\DI\Container { - return Bootstrapper::get_instance()->container(); + return \Automattic\Syndication\Infrastructure\DI\Container::instance(); } /** @@ -51,10 +65,6 @@ function syndication_container(): \Automattic\Syndication\Infrastructure\DI\Cont require_once __DIR__ . '/includes/class-wp-push-syndication-server.php'; -if ( defined( 'WP_CLI' ) && WP_CLI ) { - require_once __DIR__ . '/includes/class-wp-cli.php'; -} - $GLOBALS['push_syndication_server'] = new WP_Push_Syndication_Server(); // Create the event counter. @@ -69,19 +79,14 @@ function syndication_container(): \Automattic\Syndication\Infrastructure\DI\Cont require __DIR__ . '/includes/class-syndication-site-auto-retry.php'; new Failed_Syndication_Auto_Retry(); -// Load encryption classes. -require_once __DIR__ . '/includes/class-syndication-encryption.php'; -require_once __DIR__ . '/includes/interface-syndication-encryptor.php'; -require_once __DIR__ . '/includes/class-syndication-encryptor-mcrypt.php'; -require_once __DIR__ . '/includes/class-syndication-encryptor-openssl.php'; - -// On PHP 7.1 mcrypt is available, but will throw a deprecated error if its used. Therefore, checking for the -// PHP version, instead of checking for mcrypt is a better approach. -if ( ! defined( 'PHP_VERSION_ID' ) || PHP_VERSION_ID < 70100 ) { - $syndication_encryption = new Syndication_Encryption( new Syndication_Encryptor_MCrypt() ); -} else { - $syndication_encryption = new Syndication_Encryption( new Syndication_Encryptor_OpenSSL() ); +// Initialize syndication notifications. +( new \Automattic\Syndication\Infrastructure\Notification\SyndicationNotifier() )->register_hooks(); + +// Initialize log viewers (admin only). +if ( is_admin() ) { + ( new \Automattic\Syndication\Infrastructure\Logging\PullLogViewer() )->register(); + ( new \Automattic\Syndication\Infrastructure\Logging\PushLogViewer() )->register(); } -// @TODO: instead of saving this as a global, have it as an attribute of WP_Push_Syndication_Server. -$GLOBALS['push_syndication_encryption'] = $syndication_encryption; +// Load encryption helper functions (uses Container internally). +require_once __DIR__ . '/includes/push-syndicate-encryption.php'; diff --git a/tests/Unit/Application/Services/PullServiceTest.php b/tests/Unit/Application/Services/PullServiceTest.php index c7ec450..13f31f0 100644 --- a/tests/Unit/Application/Services/PullServiceTest.php +++ b/tests/Unit/Application/Services/PullServiceTest.php @@ -46,6 +46,10 @@ class PullServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); + // Stub WordPress functions used by SyndicationLog. + Functions\when( 'current_time' )->justReturn( '2026-01-22 12:00:00' ); + Functions\when( 'update_post_meta' )->justReturn( true ); + $this->factory = Mockery::mock( TransportFactoryInterface::class ); $this->service = new PullService( $this->factory ); } @@ -82,7 +86,8 @@ public function test_pull_from_site_returns_failure_when_wrong_post_type(): void */ public function test_pull_from_site_returns_skipped_when_site_disabled(): void { $post = Mockery::mock( WP_Post::class ); - $post->post_type = 'syn_site'; + $post->post_type = 'syn_site'; + $post->post_title = 'Test Site'; Functions\when( 'get_post' )->justReturn( $post ); Functions\when( 'get_post_meta' )->alias( @@ -105,7 +110,8 @@ function ( $post_id, $key, $single ) { */ public function test_pull_from_site_returns_failure_when_no_transport(): void { $post = Mockery::mock( WP_Post::class ); - $post->post_type = 'syn_site'; + $post->post_type = 'syn_site'; + $post->post_title = 'Test Site'; Functions\when( 'get_post' )->justReturn( $post ); Functions\when( 'get_post_meta' )->alias( @@ -133,7 +139,8 @@ function ( $post_id, $key, $single ) { */ public function test_pull_from_site_returns_success_with_no_posts(): void { $post = Mockery::mock( WP_Post::class ); - $post->post_type = 'syn_site'; + $post->post_type = 'syn_site'; + $post->post_title = 'Test Site'; $transport = Mockery::mock( PullTransportInterface::class ); @@ -178,7 +185,8 @@ function ( $hook, $value ) { */ public function test_pull_from_site_creates_new_posts(): void { $post = Mockery::mock( WP_Post::class ); - $post->post_type = 'syn_site'; + $post->post_type = 'syn_site'; + $post->post_title = 'Test Site'; $transport = Mockery::mock( PullTransportInterface::class ); @@ -243,7 +251,8 @@ function ( $hook, $value ) { */ public function test_pull_from_site_handles_posts_without_guid(): void { $post = Mockery::mock( WP_Post::class ); - $post->post_type = 'syn_site'; + $post->post_type = 'syn_site'; + $post->post_title = 'Test Site'; $transport = Mockery::mock( PullTransportInterface::class ); @@ -303,7 +312,8 @@ public function test_set_update_existing_returns_self(): void { */ public function test_pull_from_sites_processes_multiple_sites(): void { $post = Mockery::mock( WP_Post::class ); - $post->post_type = 'syn_site'; + $post->post_type = 'syn_site'; + $post->post_title = 'Test Site'; $transport = Mockery::mock( PullTransportInterface::class ); @@ -347,7 +357,8 @@ function ( $hook, $value ) { */ public function test_pull_from_site_handles_insert_error(): void { $post = Mockery::mock( WP_Post::class ); - $post->post_type = 'syn_site'; + $post->post_type = 'syn_site'; + $post->post_title = 'Test Site'; $transport = Mockery::mock( PullTransportInterface::class ); From 6abaf9c8515f393687b40446c06a91b592713b00 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Fri, 23 Jan 2026 13:29:55 +0000 Subject: [PATCH 10/16] refactor: consolidate failure handling into SiteHealthMonitor The legacy failure handling system used three interconnected classes: - Syndication_Event_Counter (counting failures) - Syndication_Site_Failure_Monitor (disabling sites after max failures) - Failed_Syndication_Auto_Retry (immediate retries before scheduled pulls) This consolidates all functionality into a single SiteHealthMonitor class that listens to the same legacy hooks fired by transports. The new class provides clearer admin notices with dismiss functionality and uses simpler option-based storage for failure counts. Co-Authored-By: Claude Opus 4.5 --- .../Health/SiteHealthMonitor.php | 334 +++++++++++++++++ includes/class-syndication-event-counter.php | 93 ----- .../class-syndication-site-auto-retry.php | 156 -------- ...class-syndication-site-failure-monitor.php | 55 --- push-syndication.php | 13 +- tests/Unit/AutoRetryValidationTest.php | 343 ------------------ tests/Unit/EventCounterTest.php | 163 --------- tests/bootstrap.php | 4 +- 8 files changed, 337 insertions(+), 824 deletions(-) create mode 100644 includes/Infrastructure/Health/SiteHealthMonitor.php delete mode 100644 includes/class-syndication-event-counter.php delete mode 100644 includes/class-syndication-site-auto-retry.php delete mode 100644 includes/class-syndication-site-failure-monitor.php delete mode 100644 tests/Unit/AutoRetryValidationTest.php delete mode 100644 tests/Unit/EventCounterTest.php diff --git a/includes/Infrastructure/Health/SiteHealthMonitor.php b/includes/Infrastructure/Health/SiteHealthMonitor.php new file mode 100644 index 0000000..615268b --- /dev/null +++ b/includes/Infrastructure/Health/SiteHealthMonitor.php @@ -0,0 +1,334 @@ +handle_pull_completed( $site_id, true ); + } elseif ( 'pull_failure' === $event_type ) { + $this->handle_pull_completed( $site_id, false ); + } + } + + /** + * Handle a completed pull operation. + * + * @param int $site_id The site post ID. + * @param bool $success Whether the pull was successful. + */ + public function handle_pull_completed( int $site_id, bool $success ): void { + if ( $success ) { + $this->handle_pull_success( $site_id ); + } else { + $this->handle_pull_failure( $site_id ); + } + } + + /** + * Handle a successful pull. + * + * @param int $site_id The site post ID. + */ + private function handle_pull_success( int $site_id ): void { + // Reset failure count on success. + $this->reset_failure_count( $site_id ); + + // Clear any pending auto-retry attempts. + delete_post_meta( $site_id, self::AUTO_RETRY_META_KEY ); + } + + /** + * Handle a failed pull. + * + * @param int $site_id The site post ID. + */ + private function handle_pull_failure( int $site_id ): void { + $failure_count = $this->increment_failure_count( $site_id ); + $max_attempts = $this->get_max_pull_attempts(); + + // If no max attempts configured, just track failures but don't disable. + if ( 0 === $max_attempts ) { + return; + } + + // Check if we should disable the site. + if ( $failure_count >= $max_attempts ) { + $this->disable_site( $site_id, $failure_count ); + return; + } + + // Try auto-retry before the next scheduled pull. + $this->schedule_auto_retry( $site_id ); + } + + /** + * Schedule an auto-retry for a failed site. + * + * @param int $site_id The site post ID. + */ + private function schedule_auto_retry( int $site_id ): void { + $site = get_post( $site_id ); + if ( ! $site instanceof \WP_Post || 'syn_site' !== $site->post_type ) { + return; + } + + // Get current auto-retry count. + $auto_retry_count = (int) get_post_meta( $site_id, self::AUTO_RETRY_META_KEY, true ); + + /** + * Filter the auto-retry limit. + * + * @param int $limit The maximum number of auto-retries. + * @param int $site_id The site post ID. + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Uses established syn_ prefix. + $auto_retry_limit = (int) apply_filters( 'syn_auto_retry_limit', self::DEFAULT_AUTO_RETRY_LIMIT, $site_id ); + + // Check if we've exceeded the auto-retry limit. + if ( $auto_retry_count >= $auto_retry_limit ) { + // Reset auto-retry count - will try again on next scheduled pull. + delete_post_meta( $site_id, self::AUTO_RETRY_META_KEY ); + return; + } + + /** + * Filter the auto-retry interval. + * + * @param int $interval The interval in seconds. + * @param int $site_id The site post ID. + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Uses established syn_ prefix. + $interval = (int) apply_filters( 'syn_auto_retry_interval', self::DEFAULT_AUTO_RETRY_INTERVAL, $site_id ); + + // Schedule the retry. + $retry_time = time() + $interval; + + wp_schedule_single_event( + $retry_time, + 'syn_pull_content', + array( array( $site ) ) + ); + + // Increment auto-retry count. + update_post_meta( $site_id, self::AUTO_RETRY_META_KEY, $auto_retry_count + 1 ); + } + + /** + * Disable a site after too many failures. + * + * @param int $site_id The site post ID. + * @param int $failure_count The number of failures. + */ + private function disable_site( int $site_id, int $failure_count ): void { + // Disable the site. + update_post_meta( $site_id, 'syn_site_enabled', 'off' ); + + // Reset counters. + $this->reset_failure_count( $site_id ); + delete_post_meta( $site_id, self::AUTO_RETRY_META_KEY ); + + // Store notice for admin display. + $this->add_disabled_site_notice( $site_id, $failure_count ); + + /** + * Fires when a site is disabled due to pull failures. + * + * @param int $site_id The site post ID. + * @param int $failure_count The number of failures that triggered the disable. + */ + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Uses established syn_ prefix. + do_action( 'syn_site_disabled', $site_id, $failure_count ); + } + + /** + * Get the maximum pull attempts before disabling. + * + * @return int Maximum attempts (0 = no limit). + */ + private function get_max_pull_attempts(): int { + return (int) get_option( 'push_syndication_max_pull_attempts', 0 ); + } + + /** + * Get the current failure count for a site. + * + * @param int $site_id The site post ID. + * @return int The failure count. + */ + private function get_failure_count( int $site_id ): int { + return (int) get_option( self::FAILURE_COUNT_OPTION_PREFIX . $site_id, 0 ); + } + + /** + * Increment the failure count for a site. + * + * @param int $site_id The site post ID. + * @return int The new failure count. + */ + private function increment_failure_count( int $site_id ): int { + $count = $this->get_failure_count( $site_id ) + 1; + update_option( self::FAILURE_COUNT_OPTION_PREFIX . $site_id, $count, false ); + return $count; + } + + /** + * Reset the failure count for a site. + * + * @param int $site_id The site post ID. + */ + private function reset_failure_count( int $site_id ): void { + delete_option( self::FAILURE_COUNT_OPTION_PREFIX . $site_id ); + } + + /** + * Add a notice about a disabled site. + * + * @param int $site_id The site post ID. + * @param int $failure_count The number of failures. + */ + private function add_disabled_site_notice( int $site_id, int $failure_count ): void { + $notices = get_option( 'syn_disabled_site_notices', array() ); + $notices[] = array( + 'site_id' => $site_id, + 'failure_count' => $failure_count, + 'time' => time(), + ); + update_option( 'syn_disabled_site_notices', $notices, false ); + } + + /** + * Display admin notices for disabled sites. + */ + public function display_disabled_site_notices(): void { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Uses established syn_ prefix. + $capability = apply_filters( 'syn_syndicate_cap', 'manage_options' ); + + if ( ! current_user_can( $capability ) ) { + return; + } + + $notices = get_option( 'syn_disabled_site_notices', array() ); + + if ( empty( $notices ) ) { + return; + } + + foreach ( $notices as $index => $notice ) { + $site = get_post( $notice['site_id'] ); + if ( ! $site instanceof \WP_Post ) { + continue; + } + + $dismiss_url = add_query_arg( + array( + 'syn_dismiss_notice' => $index, + '_wpnonce' => wp_create_nonce( 'syn_dismiss_notice_' . $index ), + ) + ); + + printf( + '

    %s %s

    ', + sprintf( + /* translators: 1: site title, 2: failure count */ + esc_html__( 'Syndication: Site "%1$s" was disabled after %2$d pull failures.', 'push-syndication' ), + esc_html( $site->post_title ), + (int) $notice['failure_count'] + ), + esc_url( $dismiss_url ), + esc_html__( 'Dismiss', 'push-syndication' ) + ); + } + } + + /** + * Handle dismissing a notice. + */ + public function handle_dismiss_notice(): void { + if ( ! isset( $_GET['syn_dismiss_notice'] ) ) { + return; + } + + $index = (int) $_GET['syn_dismiss_notice']; + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verification. + if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( $_GET['_wpnonce'], 'syn_dismiss_notice_' . $index ) ) { + return; + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Uses established syn_ prefix. + $capability = apply_filters( 'syn_syndicate_cap', 'manage_options' ); + if ( ! current_user_can( $capability ) ) { + return; + } + + $notices = get_option( 'syn_disabled_site_notices', array() ); + + if ( isset( $notices[ $index ] ) ) { + unset( $notices[ $index ] ); + $notices = array_values( $notices ); // Re-index. + update_option( 'syn_disabled_site_notices', $notices, false ); + } + + // Redirect to remove query args. + wp_safe_redirect( remove_query_arg( array( 'syn_dismiss_notice', '_wpnonce' ) ) ); + exit; + } +} diff --git a/includes/class-syndication-event-counter.php b/includes/class-syndication-event-counter.php deleted file mode 100644 index f7c0e86..0000000 --- a/includes/class-syndication-event-counter.php +++ /dev/null @@ -1,93 +0,0 @@ -_get_safe_option_name( $event_slug, $event_object_id ); - $count = get_option( $option_name, 0 ); - $count = $count + 1; - update_option( $option_name, $count ); - - /** - * Fires when a syndication event has occurred. Includes the number of times the event has occurred so far. - * - * @param string $event_slug Event type identifier. - * @param string $event_object_id Event object identifier. - * @param int $count Number of times the event has been fired. - */ - do_action( 'push_syndication_after_event', $event_slug, $event_object_id, $count ); - - /** - * Fires when a syndication event has occurred. Includes the number of times the event has occurred so far. - * - * The dynamic portion of the hook name, `$event_slug`, refers to the event slug that triggered the event. - * - * @param string $event_object_id Event object identifier. - * @param int $count Number of times the event has been fired. - */ - do_action( "push_syndication_after_event_{$event_slug}", $event_object_id, $count ); - } - - /** - * Resets an event counter. - * - * @param string $event_slug An identifier for the event. - * @param string|int $event_object_id An identifier for the object the event is associated with. - */ - public function reset_event( $event_slug, $event_object_id ) { - // Coerce the slug and ID to strings. PHP will fire appropriate warnings if the given slug and ID are not coercible. - $event_slug = (string) $event_slug; - $event_object_id = (string) $event_object_id; - - delete_option( $this->_get_safe_option_name( $event_slug, $event_object_id ) ); - } - - /** - * Creates a safe option name for the event counter options. - * - * The main thing this does is make sure that the option name does not exceed the limit of 64 characters, - * regardless of the length of $event_slug and $event_object_id. The downside here is that we cannot easily - * determine which options belong to which slugs when examine the option names directly. - * - * @param string $event_slug An identifier for the event. - * @param string|int $event_object_id An identifier for the object the event is associated with. - * - * @return string Safe option name. - */ - protected function _get_safe_option_name( $event_slug, $event_object_id ) { - return 'push_syndication_event_counter_' . md5( $event_slug . $event_object_id ); - } -} diff --git a/includes/class-syndication-site-auto-retry.php b/includes/class-syndication-site-auto-retry.php deleted file mode 100644 index 89a71ec..0000000 --- a/includes/class-syndication-site-auto-retry.php +++ /dev/null @@ -1,156 +0,0 @@ -post_type ) { - return; - } - - // Fetch the site url. - $site_url = get_post_meta( $site->ID, 'syn_feed_url', true ); - - // Validate the site has a valid-looking syndication URL. - if ( empty( $site_url ) || false === filter_var( $site_url, FILTER_VALIDATE_URL ) ) { - return; - } - - // Fetch the number of times we've tried to auto-retry. - $site_auto_retry_count = (int) get_post_meta( $site_id, 'syn_failed_auto_retry_attempts', true ); - - // Only proceed if we haven't hit the pull attempt ceiling. - if ( $failed_attempts < $max_pull_attempts ) { - - // Allow the default auto retry to be filtered. - // By default, only auto retry 3 times. - $auto_retry_limit = apply_filters( 'pull_syndication_failure_auto_retry_limit', 3 ); - - // Store the current time for repeated use below. - $time_now = time(); - - // Create a string time to be sent to the logger. - // Add 1 so our log items appear to occur a second later. - // and hence order better in the log viewer. - // Without this, sometimes when the pull occurs quickly. - // these log items appear to occur at the same time as the failure. - $log_time = gmdate( 'Y-m-d H:i:s', $time_now + 1 ); - - // Are we still below the auto retry limit? - if ( $site_auto_retry_count < $auto_retry_limit ) { - - // Yes we are.. - - // Run in one minute by default. - $auto_retry_interval = apply_filters( 'syndication_failure_auto_retry_interval', $time_now + MINUTE_IN_SECONDS ); - - Syndication_Logger::log_post_info( $site->ID, $status = 'start_auto_retry', $message = sprintf( __( 'Connection retry %1$d of %2$d to %3$s in %4$s..', 'push-syndication' ), $site_auto_retry_count + 1, $auto_retry_limit, $site_url, human_time_diff( $time_now, $auto_retry_interval ) ), $log_time, $extra = array() ); - - // Schedule a pull retry for one minute in the future. - wp_schedule_single_event( - $auto_retry_interval, // Retry in X time. - 'syn_pull_content', // Fire the syndication_auto_retry hook. - array( array( $site ) ) // The site which failed to pull. - ); - - // Increment our auto retry counter. - ++$site_auto_retry_count; - - // And update the post meta auto retry count. - update_post_meta( $site->ID, 'syn_failed_auto_retry_attempts', $site_auto_retry_count ); - } else { - - // Auto Retry limit met. - // Let's cleanup after ourselves. - $cleanup = true; - } - } else { - - // Retry attempt limit met. - // The site has been disabled, let's cleanup after ourselves. - $cleanup = true; - } - - // Should we cleanup after ourselves? - if ( $cleanup ) { - - // Remove the auto retry if there was one. - delete_post_meta( $site->ID, 'syn_failed_auto_retry_attempts' ); - - Syndication_Logger::log_post_error( $site->ID, $status = 'end_auto_retry', $message = sprintf( __( 'Failed %1$d times to reconnect to %2$s', 'push-syndication' ), $site_auto_retry_count, $site_url ), $log_time, $extra = array() ); - } - } - - /** - * Handle a site pull success event. - * - * @param int $site_id The post ID of the site which just successfully pulled. - * @param int $failed_attempts The number of pull failures this site has experienced. - */ - public function handle_pull_success_event( $site_id = 0, $failed_attempts = 0 ) { - - // Remove the auto retry if there was one. - delete_post_meta( $site_id, 'syn_failed_auto_retry_attempts' ); - } -} diff --git a/includes/class-syndication-site-failure-monitor.php b/includes/class-syndication-site-failure-monitor.php deleted file mode 100644 index f3a4d95..0000000 --- a/includes/class-syndication-site-failure-monitor.php +++ /dev/null @@ -1,55 +0,0 @@ -= $max_pull_attempts ) { - // Disable the site. - update_post_meta( $site_id, 'syn_site_enabled', false ); - - // Reset the event counter. - do_action( 'push_syndication_reset_event', 'pull_failure', $site_id ); - - // Log what happened. - Syndication_Logger::log_post_error( $site_id, 'error', sprintf( __( 'Site %1$d disabled after %2$d pull failure(s).', 'push-syndication' ), (int) $site_id, (int) $count ) ); - - do_action( 'push_syndication_site_disabled', $site_id, $count ); - } - } -} diff --git a/push-syndication.php b/push-syndication.php index 2f44cfa..393c148 100644 --- a/push-syndication.php +++ b/push-syndication.php @@ -67,17 +67,8 @@ function syndication_container(): \Automattic\Syndication\Infrastructure\DI\Cont $GLOBALS['push_syndication_server'] = new WP_Push_Syndication_Server(); -// Create the event counter. -require __DIR__ . '/includes/class-syndication-event-counter.php'; -new Syndication_Event_Counter(); - -// Create the site failure monitor. -require __DIR__ . '/includes/class-syndication-site-failure-monitor.php'; -new Syndication_Site_Failure_Monitor(); - -// Create the site auto retry functionality. -require __DIR__ . '/includes/class-syndication-site-auto-retry.php'; -new Failed_Syndication_Auto_Retry(); +// Initialize site health monitoring (replaces legacy event counter, failure monitor, and auto-retry). +( new \Automattic\Syndication\Infrastructure\Health\SiteHealthMonitor() )->register(); // Initialize syndication notifications. ( new \Automattic\Syndication\Infrastructure\Notification\SyndicationNotifier() )->register_hooks(); diff --git a/tests/Unit/AutoRetryValidationTest.php b/tests/Unit/AutoRetryValidationTest.php deleted file mode 100644 index 4032d8e..0000000 --- a/tests/Unit/AutoRetryValidationTest.php +++ /dev/null @@ -1,343 +0,0 @@ -handler = new class() { - /** - * Track whether the cron was scheduled. - * - * @var bool - */ - public $cron_scheduled = false; - - /** - * Handle a site pull failure event. - * - * @param int $site_id The post id of the site we need to retry. - * @param int $failed_attempts The number of pull failures. - */ - public function handle_pull_failure_event( $site_id = 0, $failed_attempts = 0 ) { - $site_id = (int) $site_id; - $failed_attempts = (int) $failed_attempts; - - // Fetch the allowable number of max pull attempts. - $max_pull_attempts = (int) get_option( 'push_syndication_max_pull_attempts', 0 ); - - // Bail if we've already met the max pull attempt count. - if ( ! $max_pull_attempts ) { - return; - } - - // Only proceed if we have a valid site id. - if ( 0 === $site_id ) { - return; - } - - // Fetch the site post. - $site = get_post( $site_id ); - - // Validate the site post exists and is the correct post type. - if ( ! $site instanceof WP_Post || 'syn_site' !== $site->post_type ) { - return; - } - - // Fetch the site url. - $site_url = get_post_meta( $site->ID, 'syn_feed_url', true ); - - // Validate the site has a valid-looking syndication URL. - if ( empty( $site_url ) || false === filter_var( $site_url, FILTER_VALIDATE_URL ) ) { - return; - } - - // If we got here, all validations passed. - // In the real code, this would schedule a cron job. - $this->cron_scheduled = true; - } - }; - } - - /** - * Test that validation fails when site_id is 0. - */ - public function test_returns_early_when_site_id_is_zero() { - Functions\expect( 'get_option' ) - ->once() - ->with( 'push_syndication_max_pull_attempts', 0 ) - ->andReturn( 3 ); - - Functions\expect( 'get_post' )->never(); - - $this->handler->handle_pull_failure_event( 0, 1 ); - - $this->assertFalse( $this->handler->cron_scheduled ); - } - - /** - * Test that validation fails when max_pull_attempts is 0. - */ - public function test_returns_early_when_max_pull_attempts_is_zero() { - Functions\expect( 'get_option' ) - ->once() - ->with( 'push_syndication_max_pull_attempts', 0 ) - ->andReturn( 0 ); - - Functions\expect( 'get_post' )->never(); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertFalse( $this->handler->cron_scheduled ); - } - - /** - * Test that validation fails when post doesn't exist. - */ - public function test_returns_early_when_post_does_not_exist() { - Functions\expect( 'get_option' ) - ->once() - ->andReturn( 3 ); - - Functions\expect( 'get_post' ) - ->once() - ->with( 123 ) - ->andReturn( null ); - - Functions\expect( 'get_post_meta' )->never(); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertFalse( $this->handler->cron_scheduled ); - } - - /** - * Test that validation fails when post is wrong type. - */ - public function test_returns_early_when_post_is_wrong_type() { - $wrong_post = Mockery::mock( WP_Post::class ); - $wrong_post->ID = 123; - $wrong_post->post_type = 'post'; - - Functions\expect( 'get_option' ) - ->once() - ->andReturn( 3 ); - - Functions\expect( 'get_post' ) - ->once() - ->with( 123 ) - ->andReturn( $wrong_post ); - - Functions\expect( 'get_post_meta' )->never(); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertFalse( $this->handler->cron_scheduled ); - } - - /** - * Test that validation fails when syndication URL is empty. - */ - public function test_returns_early_when_syndication_url_is_empty() { - $site = Mockery::mock( WP_Post::class ); - $site->ID = 123; - $site->post_type = 'syn_site'; - - Functions\expect( 'get_option' ) - ->once() - ->andReturn( 3 ); - - Functions\expect( 'get_post' ) - ->once() - ->with( 123 ) - ->andReturn( $site ); - - Functions\expect( 'get_post_meta' ) - ->once() - ->with( 123, 'syn_feed_url', true ) - ->andReturn( '' ); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertFalse( $this->handler->cron_scheduled ); - } - - /** - * Test that validation fails when syndication URL is invalid. - */ - public function test_returns_early_when_syndication_url_is_invalid() { - $site = Mockery::mock( WP_Post::class ); - $site->ID = 123; - $site->post_type = 'syn_site'; - - Functions\expect( 'get_option' ) - ->once() - ->andReturn( 3 ); - - Functions\expect( 'get_post' ) - ->once() - ->with( 123 ) - ->andReturn( $site ); - - Functions\expect( 'get_post_meta' ) - ->once() - ->with( 123, 'syn_feed_url', true ) - ->andReturn( 'not-a-valid-url' ); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertFalse( $this->handler->cron_scheduled ); - } - - /** - * Test that validation passes with valid site. - */ - public function test_validation_passes_with_valid_site() { - $site = Mockery::mock( WP_Post::class ); - $site->ID = 123; - $site->post_type = 'syn_site'; - - Functions\expect( 'get_option' ) - ->once() - ->andReturn( 3 ); - - Functions\expect( 'get_post' ) - ->once() - ->with( 123 ) - ->andReturn( $site ); - - Functions\expect( 'get_post_meta' ) - ->once() - ->with( 123, 'syn_feed_url', true ) - ->andReturn( 'https://example.com/feed/' ); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertTrue( $this->handler->cron_scheduled ); - } - - /** - * Test that validation passes with various valid URLs. - * - * @dataProvider valid_url_provider - * - * @param string $url The URL to test. - */ - public function test_validation_passes_with_various_valid_urls( $url ) { - $site = Mockery::mock( WP_Post::class ); - $site->ID = 123; - $site->post_type = 'syn_site'; - - Functions\expect( 'get_option' ) - ->once() - ->andReturn( 3 ); - - Functions\expect( 'get_post' ) - ->once() - ->andReturn( $site ); - - Functions\expect( 'get_post_meta' ) - ->once() - ->andReturn( $url ); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertTrue( $this->handler->cron_scheduled, "URL '$url' should be valid" ); - } - - /** - * Provide valid URLs for testing. - * - * @return array - */ - public function valid_url_provider() { - return [ - 'https url' => [ 'https://example.com/feed/' ], - 'http url' => [ 'http://example.com/feed.xml' ], - 'url with port' => [ 'https://example.com:8080/feed/' ], - 'url with query' => [ 'https://example.com/feed/?format=rss' ], - 'url with fragment' => [ 'https://example.com/feed/#section' ], - 'subdomain url' => [ 'https://blog.example.com/feed/' ], - 'url with path' => [ 'https://example.com/api/v1/posts/feed' ], - ]; - } - - /** - * Test that validation fails with various invalid URLs. - * - * @dataProvider invalid_url_provider - * - * @param string $url The URL to test. - */ - public function test_validation_fails_with_various_invalid_urls( $url ) { - $site = Mockery::mock( WP_Post::class ); - $site->ID = 123; - $site->post_type = 'syn_site'; - - Functions\expect( 'get_option' ) - ->once() - ->andReturn( 3 ); - - Functions\expect( 'get_post' ) - ->once() - ->andReturn( $site ); - - Functions\expect( 'get_post_meta' ) - ->once() - ->andReturn( $url ); - - $this->handler->handle_pull_failure_event( 123, 1 ); - - $this->assertFalse( $this->handler->cron_scheduled, "URL '$url' should be invalid" ); - } - - /** - * Provide invalid URLs for testing. - * - * @return array - */ - public function invalid_url_provider() { - return [ - 'empty string' => [ '' ], - 'just text' => [ 'not-a-url' ], - 'missing protocol' => [ 'example.com/feed/' ], - 'just protocol' => [ 'https://' ], - 'javascript' => [ 'javascript:alert(1)' ], - 'relative path' => [ '/feed/rss' ], - 'spaces' => [ 'https://example .com/' ], - ]; - } -} diff --git a/tests/Unit/EventCounterTest.php b/tests/Unit/EventCounterTest.php deleted file mode 100644 index b1669c1..0000000 --- a/tests/Unit/EventCounterTest.php +++ /dev/null @@ -1,163 +0,0 @@ -counter = new \Syndication_Event_Counter(); - } - - /** - * Test safe option name generation uses correct prefix. - */ - public function test_get_safe_option_name_has_correct_prefix(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - $result = $method->invoke( $this->counter, 'test_event', '123' ); - - $this->assertStringStartsWith( 'push_syndication_event_counter_', $result ); - } - - /** - * Test safe option name generation produces consistent hashes. - */ - public function test_get_safe_option_name_is_consistent(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - $result1 = $method->invoke( $this->counter, 'test_event', '123' ); - $result2 = $method->invoke( $this->counter, 'test_event', '123' ); - - $this->assertSame( $result1, $result2 ); - } - - /** - * Test safe option name generation produces different hashes for different slugs. - */ - public function test_get_safe_option_name_different_slugs(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - $result1 = $method->invoke( $this->counter, 'event_a', '123' ); - $result2 = $method->invoke( $this->counter, 'event_b', '123' ); - - $this->assertNotSame( $result1, $result2 ); - } - - /** - * Test safe option name generation produces different hashes for different object IDs. - */ - public function test_get_safe_option_name_different_object_ids(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - $result1 = $method->invoke( $this->counter, 'test_event', '123' ); - $result2 = $method->invoke( $this->counter, 'test_event', '456' ); - - $this->assertNotSame( $result1, $result2 ); - } - - /** - * Test safe option name length does not exceed 64 characters. - */ - public function test_get_safe_option_name_max_length(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - // Test with very long slug and ID. - $long_slug = str_repeat( 'very_long_event_slug_', 10 ); - $long_id = str_repeat( '123456789', 10 ); - - $result = $method->invoke( $this->counter, $long_slug, $long_id ); - - $this->assertLessThanOrEqual( 64, strlen( $result ) ); - } - - /** - * Test safe option name uses MD5 hash. - */ - public function test_get_safe_option_name_uses_md5(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - $result = $method->invoke( $this->counter, 'test', '123' ); - $expected = 'push_syndication_event_counter_' . md5( 'test123' ); - - $this->assertSame( $expected, $result ); - } - - /** - * Test safe option name handles empty slug. - */ - public function test_get_safe_option_name_empty_slug(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - $result = $method->invoke( $this->counter, '', '123' ); - $expected = 'push_syndication_event_counter_' . md5( '123' ); - - $this->assertSame( $expected, $result ); - } - - /** - * Test safe option name handles empty object ID. - */ - public function test_get_safe_option_name_empty_object_id(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - $result = $method->invoke( $this->counter, 'test_event', '' ); - $expected = 'push_syndication_event_counter_' . md5( 'test_event' ); - - $this->assertSame( $expected, $result ); - } - - /** - * Test safe option name handles numeric object ID. - */ - public function test_get_safe_option_name_numeric_id(): void { - $method = new ReflectionMethod( \Syndication_Event_Counter::class, '_get_safe_option_name' ); - $method->setAccessible( true ); - - // The method converts to string internally. - $result = $method->invoke( $this->counter, 'pull_failure', 42 ); - $expected = 'push_syndication_event_counter_' . md5( 'pull_failure42' ); - - $this->assertSame( $expected, $result ); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ed49b7d..93f18ed 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -50,8 +50,7 @@ define( 'MINUTE_IN_SECONDS', 60 ); } - // Load classes needed for unit tests (those without WordPress dependencies). - require_once dirname( __DIR__ ) . '/includes/class-syndication-event-counter.php'; + // Load test case base class. require_once __DIR__ . '/Unit/TestCase.php'; return; @@ -81,5 +80,4 @@ function (): void { * Load test dependencies. */ require_once __DIR__ . '/Integration/EncryptorTestCase.php'; - require_once __DIR__ . '/Integration/Syndication_Mock_Client.php'; } From 366a0ddf31362870fec8e09521c229c336091597 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Fri, 23 Jan 2026 14:23:41 +0000 Subject: [PATCH 11/16] refactor: remove duplicate action handlers and legacy admin notices - Remove duplicate syn_pull_content, syn_push_content, syn_schedule_push_content handlers from server class (now handled by PluginBootstrapper) - Remove Syndication_Logger::init() call (no longer needed for writing) - Delete Syndication_Admin_Notices class (was only used by legacy logger) - Keep Syndication_Logger for reading existing legacy logs - Legacy log viewer instantiated directly, only shows when legacy logs exist This eliminates double processing of cron events and removes unused admin notice infrastructure while preserving backward compatibility for viewing existing legacy logs. Co-Authored-By: Claude Opus 4.5 --- includes/class-syndication-admin-notices.php | 197 ------------------ includes/class-wp-push-syndication-server.php | 7 +- push-syndication.php | 8 +- 3 files changed, 8 insertions(+), 204 deletions(-) delete mode 100644 includes/class-syndication-admin-notices.php diff --git a/includes/class-syndication-admin-notices.php b/includes/class-syndication-admin-notices.php deleted file mode 100644 index 197e7ea..0000000 --- a/includes/class-syndication-admin-notices.php +++ /dev/null @@ -1,197 +0,0 @@ - sanitize_text_field( $message_text ), - 'summarize_multiple' => (bool) $summarize_multiple, - 'message_type' => sanitize_text_field( $message_type ), - 'class' => sanitize_text_field( $class ), - ); - $changed = true; - } - - if ( true === $changed ) { - update_option( self::$notice_option, $notices ); - } - - return true; - } - - /** - * Evaluate and display valid notices - */ - public static function display_valid_notices() { - $capability = apply_filters( 'syn_syndicate_cap', 'manage_options' ); - - $messages = get_option( self::$notice_option ); - $notice_bundles = get_option( self::$notice_bundles_option ); - $messages_to_display = array(); - $notice_bundles_changed = false; - - if ( ! is_array( $messages ) || empty( $messages ) ) { - return; - } - - foreach ( $messages as $message_type => $message_values ) { - foreach ( $message_values as $message_key => $message_data ) { - if ( isset( $message_data['summarize_multiple'] ) && true === $message_data['summarize_multiple'] ) { - $message_text = apply_filters( 'syn_message_text_multiple', $message_data['message_text'], $message_data ); - } else { - $message_text = apply_filters( 'syn_message_text', $message_data['message_text'], $message_data ); - } - - $new_message_key = md5( $message_type . $message_text ); - $new_message_data = array( - 'message_text' => sanitize_text_field( $message_text ), - 'summarize_multiple' => (bool) $message_data['summarize_multiple'], - 'message_type' => sanitize_text_field( $message_data['message_type'] ), - 'class' => sanitize_text_field( $message_data['class'] ), - ); - - if ( $new_message_key != $message_key ) { - if ( ! isset( $notice_bundles[ $new_message_key ] ) || ! in_array( $message_key, $notice_bundles[ $new_message_key ] ) ) { - $notice_bundles[ $new_message_key ][] = $message_key; - $notice_bundles_changed = true; - } - } - - if ( current_user_can( $capability ) ) { - $messages_to_display[ $message_type ][ $new_message_key ] = $new_message_data; - } - } - } - - if ( true === $notice_bundles_changed ) { - update_option( self::$notice_bundles_option, $notice_bundles ); - } - - foreach ( $messages_to_display as $message_type => $message_values ) { - foreach ( $message_values as $message_key => $message_data ) { - $dismiss_nonce = wp_create_nonce( esc_attr( $message_key ) ); - printf( '

    ', esc_attr( $message_data['class'] ) ); - echo wp_kses( - sprintf( - /* translators: 1: message type, 2: message text, 3: dismiss URL */ - __( '%1$s : %2$s Hide Notice', 'push-syndication' ), - esc_html( $message_type ), - wp_kses_post( $message_data['message_text'] ), - esc_url( - add_query_arg( - array( - self::$dismiss_parameter => esc_attr( $message_key ), - 'syn_dismiss_nonce' => esc_attr( $dismiss_nonce ), - ) - ) - ) - ), - array( 'a' => array( 'href' => array() ) ) - ); - printf( '

    ' ); - } - } - } - - /** - * Handle dismissing of notices - */ - public static function handle_dismiss_syndication_notice() { - $capability = apply_filters( 'syn_syndicate_cap', 'manage_options' ); - - // Add nonce. - if ( isset( $_GET[ self::$dismiss_parameter ] ) && current_user_can( $capability ) ) { - $dismiss_key = esc_attr( $_GET[ self::$dismiss_parameter ] ); - $dismiss_nonce = esc_attr( $_GET['syn_dismiss_nonce'] ); - if ( ! wp_verify_nonce( $dismiss_nonce, $dismiss_key ) ) { - wp_die( esc_html__( 'Invalid security check', 'push-syndication' ) ); - } - $messages = get_option( self::$notice_option ); - $notice_bundles = get_option( self::$notice_bundles_option ); - - $dismiss_items = array(); - if ( isset( $notice_bundles[ $dismiss_key ] ) ) { - $dismiss_items = $notice_bundles[ $dismiss_key ]; - } else { - $dismiss_items = array( $dismiss_key ); - } - - foreach ( $messages as $message_type => $message_values ) { - $message_keys = array_keys( $message_values ); - $dismiss_it = array_intersect( $message_keys, $dismiss_items ); - foreach ( $dismiss_it as $dismiss_it_key ) { - unset( $messages[ $message_type ][ $dismiss_it_key ] ); - } - } - - if ( isset( $notice_bundles[ $dismiss_key ] ) ) { - unset( $notice_bundles[ $dismiss_key ] ); - } - - update_option( self::$notice_option, $messages ); - update_option( self::$notice_bundles_option, $notice_bundles ); - } - } -} - -add_filter( 'syn_message_text_multiple', 'syn_handle_multiple_error_notices', 10, 2 ); -/** - * Filter callback to handle multiple error notices. - * - * @param string $message The original message text. - * @param array $message_data Additional message data. - * @return string The filtered message text. - */ -function syn_handle_multiple_error_notices( $message, $message_data ) { - return __( 'There have been multiple errors. Please validate your syndication logs' ); -} - -add_action( 'push_syndication_site_disabled', 'syn_add_site_disabled_notice', 10, 2 ); -/** - * Display an admin notice when a syndication site is disabled. - * - * @param int $site_id The ID of the disabled site. - * @param int $count The number of failed pull attempts. - */ -function syn_add_site_disabled_notice( $site_id, $count ) { - Syndication_Logger_Admin_Notice::add_notice( $message_text = sprintf( __( 'Site %1$d disabled after %2$d pull failure(s).', 'push-syndication' ), (int) $site_id, (int) $count ), $message_type = 'Syndication site disabled', $class = 'error', $summarize_multiple = false ); -} diff --git a/includes/class-wp-push-syndication-server.php b/includes/class-wp-push-syndication-server.php index cd19d2b..df1da9a 100644 --- a/includes/class-wp-push-syndication-server.php +++ b/includes/class-wp-push-syndication-server.php @@ -190,12 +190,11 @@ public function init() { } public function register_syndicate_actions() { - add_action( 'syn_schedule_push_content', array( $this, 'schedule_push_content' ), 10, 2 ); + // Note: syn_schedule_push_content, syn_push_content, and syn_pull_content + // are now handled by PluginBootstrapper using the new service layer. + // Only delete content actions remain here. add_action( 'syn_schedule_delete_content', array( $this, 'schedule_delete_content' ) ); - - add_action( 'syn_push_content', array( $this, 'push_content' ) ); add_action( 'syn_delete_content', array( $this, 'delete_content' ) ); - add_action( 'syn_pull_content', array( $this, 'pull_content' ), 10, 1 ); } public function add_new_columns( $columns ) { diff --git a/push-syndication.php b/push-syndication.php index 393c148..ec749ba 100644 --- a/push-syndication.php +++ b/push-syndication.php @@ -58,11 +58,9 @@ function syndication_container(): \Automattic\Syndication\Infrastructure\DI\Cont } /** - * Load syndication logger + * Load legacy classes still needed. */ require_once __DIR__ . '/includes/class-syndication-logger.php'; -Syndication_Logger::init(); - require_once __DIR__ . '/includes/class-wp-push-syndication-server.php'; $GLOBALS['push_syndication_server'] = new WP_Push_Syndication_Server(); @@ -77,6 +75,10 @@ function syndication_container(): \Automattic\Syndication\Infrastructure\DI\Cont if ( is_admin() ) { ( new \Automattic\Syndication\Infrastructure\Logging\PullLogViewer() )->register(); ( new \Automattic\Syndication\Infrastructure\Logging\PushLogViewer() )->register(); + + // Legacy log viewer (only shows if legacy logs exist). + require_once __DIR__ . '/includes/class-syndication-logger-viewer.php'; + new Syndication_Logger_Viewer(); } // Load encryption helper functions (uses Container internally). From 15ceb1241c17755fd24f0dc9ef40f0e693812023 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Fri, 23 Jan 2026 14:36:33 +0000 Subject: [PATCH 12/16] chore: remove unused Walker_CategoryDropdownMultiple class This class was not referenced anywhere in the codebase. Co-Authored-By: Claude Opus 4.5 --- ...lass-walker-category-dropdown-multiple.php | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 includes/class-walker-category-dropdown-multiple.php diff --git a/includes/class-walker-category-dropdown-multiple.php b/includes/class-walker-category-dropdown-multiple.php deleted file mode 100644 index 7e06e69..0000000 --- a/includes/class-walker-category-dropdown-multiple.php +++ /dev/null @@ -1,51 +0,0 @@ - 'parent', - 'id' => 'term_id', - ); - - /** - * Start the element output. - * - * @see Walker::start_el() - * - * @param string $output Passed by reference. Used to append additional content. - * @param object $category Category data object. - * @param int $depth Depth of category in reference to parents. Default 0. - * @param array $args An array of arguments. @see wp_list_categories(). - * @param int $current_object_id ID of the current category. - */ - public function start_el( &$output, $category, $depth = 0, $args = array(), $current_object_id = 0 ) { - $pad = str_repeat( ' ', $depth * 3 ); - - $cat_name = apply_filters( 'list_cats', $category->name, $category ); - - $output .= "\t\n"; - } -} From aed74aa18a9ccafd8c65adc5a3a72da98467c760 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Fri, 23 Jan 2026 15:25:56 +0000 Subject: [PATCH 13/16] refactor: remove dead code from WP_Push_Syndication_Server class Eliminates 335 lines of code that duplicated functionality already implemented in the new service-oriented architecture. The removed hook registrations and methods (schedule_push_content, push_content, pull_content, refresh_pull_jobs, and related handlers) were superseded by PluginBootstrapper's event handling and the PushService/PullService implementations. This continues the architectural migration from the monolithic legacy class towards proper separation of concerns through dependency injection and dedicated service classes. The removed code was entirely unreachable, as the new architecture takes precedence in hook execution order. Co-Authored-By: Claude Opus 4.5 --- includes/class-wp-push-syndication-server.php | 343 +----------------- 1 file changed, 8 insertions(+), 335 deletions(-) diff --git a/includes/class-wp-push-syndication-server.php b/includes/class-wp-push-syndication-server.php index df1da9a..c4d7bc5 100644 --- a/includes/class-wp-push-syndication-server.php +++ b/includes/class-wp-push-syndication-server.php @@ -67,16 +67,8 @@ public function __construct() { add_action( 'transition_post_status', array( $this, 'pre_schedule_push_content' ), 10, 3 ); add_action( 'delete_post', array( $this, 'schedule_delete_content' ) ); - // Handle changes to sites and site groups. - add_action( 'save_post', array( $this, 'handle_site_change' ) ); - add_action( 'delete_post', array( $this, 'handle_site_change' ) ); - add_action( 'create_term', array( $this, 'handle_site_group_change' ), 10, 3 ); - add_action( 'delete_term', array( $this, 'handle_site_group_change' ), 10, 3 ); - - // Generic hook for reprocessing all scheduled pull jobs. This allows - // for bulk rescheduling of jobs that were scheduled the old way (one job. - // for many sites). - add_action( 'syn_refresh_pull_jobs', array( $this, 'refresh_pull_jobs' ) ); + // Note: save_post, delete_post, create_term, delete_term for site/sitegroup changes + // and syn_refresh_pull_jobs are now handled by PluginBootstrapper using the new service layer. // AJAX handler for testing credentials. add_action( 'wp_ajax_syn_test_credentials', array( $this, 'ajax_test_credentials' ) ); @@ -1539,106 +1531,8 @@ public function pre_schedule_push_content( $new_status, $old_status, $post ) { do_action( 'syn_schedule_push_content', $post->ID, $sites ); } - public function schedule_push_content( $post_id, $sites ) { - wp_schedule_single_event( - time() - 1, - 'syn_push_content', - array( $sites ) - ); - } - - /** - * Cron job function to syndicate content. - * - * @param array $sites Array of sites data to syndicate content to. - */ - public function push_content( $sites ) { - - // if another process running on it return. - if ( get_transient( 'syn_syndicate_lock' ) == 'locked' ) { - return; - } - - // set value as locked, valid for 5 mins. - set_transient( 'syn_syndicate_lock', 'locked', 60 * 5 ); - - /** Start of critical section. */ - - $post_ID = $sites['post_ID']; - - // an array containing states of sites. - $slave_post_states = get_post_meta( $post_ID, '_syn_slave_post_states', true ); - $slave_post_states = ! empty( $slave_post_states ) ? $slave_post_states : array(); - - $sites = apply_filters( 'syn_pre_push_post_sites', $sites, $post_ID, $slave_post_states ); - - if ( ! empty( $sites['selected_sites'] ) ) { - foreach ( $sites['selected_sites'] as $site ) { - $transport_type = get_post_meta( $site->ID, 'syn_transport_type', true ); - $client = Syndication_Client_Factory::get_client( $transport_type, $site->ID ); - $info = $this->get_site_info( $site->ID, $slave_post_states, $client ); - - // Check if post already exists on target to prevent syndication loops. - if ( in_array( $transport_type, array( 'WP_REST', 'WP_XMLRPC' ), true ) ) { - $unique_id = get_post_meta( $post_ID, 'post_uniqueid', true ); - - if ( ! empty( $unique_id ) && $client->is_source_site_post( 'post_uniqueid', $unique_id ) ) { - continue; - } - } - - if ( 'new' === $info['state'] || 'new-error' === $info['state'] ) { // States 'new' and 'new-error'. - - $push_new_shortcircuit = apply_filters( 'syn_pre_push_new_post_shortcircuit', false, $post_ID, $site, $transport_type, $client, $info ); - if ( true === $push_new_shortcircuit ) { - continue; - } - - $result = $client->new_post( $post_ID ); - - $this->validate_result_new_post( $result, $slave_post_states, $site->ID, $client ); - $this->update_slave_post_states( $post_ID, $slave_post_states ); - - do_action( 'syn_post_push_new_post', $result, $post_ID, $site, $transport_type, $client, $info ); - } else { // States 'success', 'edit-error' and 'remove-error'. - $push_edit_shortcircuit = apply_filters( 'syn_pre_push_edit_post_shortcircuit', false, $post_ID, $site, $transport_type, $client, $info ); - if ( true === $push_edit_shortcircuit ) { - continue; - } - - $result = $client->edit_post( $post_ID, $info['ext_ID'] ); - - $this->validate_result_edit_post( $result, $info['ext_ID'], $slave_post_states, $site->ID, $client ); - $this->update_slave_post_states( $post_ID, $slave_post_states ); - - do_action( 'syn_post_push_edit_post', $result, $post_ID, $site, $transport_type, $client, $info ); - } - } - } - - if ( ! empty( $sites['removed_sites'] ) ) { - foreach ( $sites['removed_sites'] as $site ) { - $transport_type = get_post_meta( $site->ID, 'syn_transport_type', true ); - $client = Syndication_Client_Factory::get_client( $transport_type, $site->ID ); - $info = $this->get_site_info( $site->ID, $slave_post_states, $client ); - - // if the post is not pushed we do not need to delete them. - if ( 'success' === $info['state'] || 'edit-error' === $info['state'] || 'remove-error' === $info['state'] ) { - $result = $client->delete_post( $info['ext_ID'] ); - if ( is_wp_error( $result ) ) { - $slave_post_states['remove-error'][ $site->ID ] = $result; - $this->update_slave_post_states( $post_ID, $slave_post_states ); - } - } - } - } - - - /** End of critical section. */ - - // release the lock. - delete_transient( 'syn_syndicate_lock' ); - } + // Note: schedule_push_content and push_content are now handled by + // PluginBootstrapper using the new service layer. public function get_sites_by_post_ID( $post_ID ) { @@ -1994,231 +1888,10 @@ public function schedule_pull_content( $sites ) { update_option( 'syn_old_pull_sites', $sites ); } - public function pull_get_selected_sites() { - $selected_sitegroups = $this->push_syndicate_settings['selected_pull_sitegroups']; - - $sites = array(); - foreach ( $selected_sitegroups as $selected_sitegroup ) { - $sites = array_merge( $sites, $this->get_sites_by_sitegroup( $selected_sitegroup ) ); - } - - // Order by last update date. - usort( $sites, array( $this, 'sort_sites_by_last_pull_date' ) ); - - return $sites; - } - - private function sort_sites_by_last_pull_date( $site_a, $site_b ) { - $site_a_pull_date = (int) get_post_meta( $site_a->ID, 'syn_last_pull_time', true ); - $site_b_pull_date = (int) get_post_meta( $site_b->ID, 'syn_last_pull_time', true ); - - if ( $site_a_pull_date == $site_b_pull_date ) { - return 0; - } - - return ( $site_a_pull_date < $site_b_pull_date ) ? -1 : 1; - } - - public function pull_content( $sites = array() ) { - add_filter( 'http_headers_useragent', array( $this, 'syndication_user_agent' ) ); - - if ( empty( $sites ) ) { - $sites = $this->pull_get_selected_sites(); - } - - // Treat this process as an import. - if ( ! defined( 'WP_IMPORTING' ) ) { - define( 'WP_IMPORTING', true ); - } - - // Temporarily suspend comment and term counting and cache invalidation. - wp_defer_term_counting( true ); - wp_defer_comment_counting( true ); - wp_suspend_cache_invalidation( true ); - - // Keep track of posts that are added or changed. - $updated_post_ids = array(); - - foreach ( $sites as $site ) { - $site_id = $site->ID; - - $site_enabled = get_post_meta( $site_id, 'syn_site_enabled', true ); - if ( 'on' !== $site_enabled ) { - continue; - } - - $transport_type = get_post_meta( $site_id, 'syn_transport_type', true ); - $client = Syndication_Client_Factory::get_client( $transport_type, $site_id ); - $posts = apply_filters( 'syn_pre_pull_posts', $client->get_posts(), $site, $client ); - - $post_types_processed = array(); - - if ( is_array( $posts ) && count( $posts ) > 0 ) { - Syndication_Logger::log_post_info( $site_id, $status = 'start_import', $message = sprintf( __( 'starting import for site id %1$d with %2$d posts', 'push-syndication' ), $site_id, count( $posts ) ), $log_time = null, $extra = array() ); - } else { - Syndication_Logger::log_post_info( $site_id, $status = 'no_posts', $message = sprintf( __( 'no posts for site id %d', 'push-syndication' ), $site_id ), $log_time = null, $extra = array() ); - } - - if ( is_array( $posts ) && ! empty( $posts ) ) { - foreach ( $posts as $post ) { - if ( ! in_array( $post['post_type'], $post_types_processed ) ) { - remove_post_type_support( $post['post_type'], 'revisions' ); - $post_types_processed[] = $post['post_type']; - } - - if ( empty( $post['post_guid'] ) ) { - Syndication_Logger::log_post_error( $site_id, $status = 'no_post_guid', $message = sprintf( __( 'skipping post no guid', 'push-syndication' ) ), $log_time = null, $extra = array( 'post' => $post ) ); - continue; - } - $post_id = $this->find_post_by_guid( $post['post_guid'], $post, $site ); - - if ( $post_id ) { - $pull_edit_shortcircuit = apply_filters( 'syn_pre_pull_edit_post_shortcircuit', false, $post, $site, $transport_type, $client ); - if ( true === $pull_edit_shortcircuit ) { - Syndication_Logger::log_post_info( $site_id, $status = 'skip_pre_pull_edit_post', $message = sprintf( __( 'skipping post per syn_pre_pull_edit_post_shortcircuit', 'push-syndication' ) ), $log_time = null, $extra = array( 'post' => $post ) ); - continue; - } - // if updation is disabled continue. - if ( 'on' !== $this->push_syndicate_settings['update_pulled_posts'] ) { - Syndication_Logger::log_post_info( $site_id, $status = 'skip_update_pulled_posts', $message = sprintf( __( 'skipping post update per update_pulled_posts setting', 'push-syndication' ) ), $log_time = null, $extra = array( 'post' => $post ) ); - continue; - } - $post['ID'] = $post_id; - - $post = apply_filters( 'syn_pull_edit_post', $post, $site, $client ); - - $result = wp_update_post( $post, true ); - - do_action( 'syn_post_pull_edit_post', $result, $post, $site, $transport_type, $client ); - - $updated_post_ids[] = (int) $result; - } else { - $pull_new_shortcircuit = apply_filters( 'syn_pre_pull_new_post_shortcircuit', false, $post, $site, $transport_type, $client ); - if ( true === $pull_new_shortcircuit ) { - Syndication_Logger::log_post_info( $site_id, $status = 'syn_pre_pull_new_post_shortcircuit', $message = sprintf( __( 'skipping post per syn_pre_pull_edit_post_shortcircuit', 'push-syndication' ) ), $log_time = null, $extra = array( 'post' => $post ) ); - continue; - } - $post = apply_filters( 'syn_pull_new_post', $post, $site, $client ); - - $result = wp_insert_post( $post, true ); - - do_action( 'syn_post_pull_new_post', $result, $post, $site, $transport_type, $client ); - - if ( ! is_wp_error( $result ) ) { - update_post_meta( $result, 'syn_post_guid', $post['post_guid'] ); - update_post_meta( $result, 'syn_source_site_id', $site_id ); - } - - $updated_post_ids[] = (int) $result; - } - } - - foreach ( $post_types_processed as $post_type ) { - add_post_type_support( $post_type, 'revisions' ); - } - } - - update_post_meta( $site_id, 'syn_last_pull_time', time() ); - } - - // Resume comment and term counting and cache invalidation. - wp_suspend_cache_invalidation( false ); - wp_defer_term_counting( false ); - wp_defer_comment_counting( false ); - - // Clear the caches for any posts that were updated. - foreach ( $updated_post_ids as $updated_post_id ) { - clean_post_cache( $updated_post_id ); - } - - remove_filter( 'http_headers_useragent', array( $this, 'syndication_user_agent' ) ); - } - - public function syndication_user_agent( $user_agent ) { - return apply_filters( 'syn_pull_user_agent', self::CUSTOM_USER_AGENT ); - } - - public function find_post_by_guid( $guid, $post, $site ) { - global $wpdb; - - $post_id = apply_filters( 'syn_pre_find_post_by_guid', false, $guid, $post, $site ); - if ( false !== $post_id ) { - return $post_id; - } - - // A direct query here is way more efficient than WP_Query, because we don't have to do all the extra processing, filters, and JOIN. - $post_id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = 'syn_post_guid' AND meta_value = %s LIMIT 1", $guid ) ); - - if ( $post_id ) { - return $post_id; - } - - return false; - } - - /** - * Reschedule all scheduled pull jobs. - */ - public function refresh_pull_jobs() { - $sites = $this->pull_get_selected_sites(); - - $this->schedule_pull_content( $sites ); - } - - /** - * Handle save_post and delete_post for syn_site posts. - * - * If a syn_site post is updated or deleted we should reprocess any scheduled pull jobs. - * - * @param int $post_id The post ID. - */ - public function handle_site_change( $post_id ) { - if ( 'syn_site' === get_post_type( $post_id ) ) { - $this->schedule_deferred_pull_jobs_refresh(); - } - } - - /** - * Handle create_term and delete_term for syn_sitegroup terms. - * - * If a site group is created or deleted we should reprocess any scheduled pull jobs. - * - * @param int $term The term ID. - * @param int $tt_id The term taxonomy ID. - * @param string $taxonomy The taxonomy slug. - */ - public function handle_site_group_change( $term, $tt_id, $taxonomy ) { - if ( 'syn_sitegroup' === $taxonomy ) { - $this->schedule_deferred_pull_jobs_refresh(); - } - } - - /** - * Schedule a deferred refresh of pull jobs. - * - * This prevents timeout issues when many sites are configured by deferring - * the refresh to a background cron event instead of running synchronously. - * - * @since 2.2.0 - * - * @return void - */ - private function schedule_deferred_pull_jobs_refresh() { - // Use a transient to debounce multiple requests within a short time window. - $debounce_key = 'syn_pull_jobs_refresh_pending'; - - if ( get_transient( $debounce_key ) ) { - // Already scheduled, don't schedule again. - return; - } - - // Set transient for 2 minutes to prevent duplicate scheduling. - set_transient( $debounce_key, '1', 2 * MINUTE_IN_SECONDS ); - - // Clear any existing scheduled refresh and schedule a new one. - wp_clear_scheduled_hook( 'syn_refresh_pull_jobs' ); - wp_schedule_single_event( time() + 60, 'syn_refresh_pull_jobs' ); - } + // Note: pull_get_selected_sites, sort_sites_by_last_pull_date, pull_content, + // syndication_user_agent, find_post_by_guid, refresh_pull_jobs, handle_site_change, + // handle_site_group_change, and schedule_deferred_pull_jobs_refresh are now handled + // by PluginBootstrapper using the new service layer. private function upgrade() { global $wpdb; From aecc3b0e30c29c66332e796834ff009eb95b002c Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Fri, 23 Jan 2026 19:56:51 +0000 Subject: [PATCH 14/16] refactor: consolidate cron schedule registration The cron_add_pull_time_interval method in WP_Push_Syndication_Server duplicated functionality now provided by PluginBootstrapper::on_cron_schedules(). Removed the duplicate method and filter registration to centralise cron schedule management in the bootstrapper, eliminating 20 lines of redundant code and ensuring a single source of truth for custom cron intervals. Co-Authored-By: Claude Opus 4.5 --- includes/class-wp-push-syndication-server.php | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/includes/class-wp-push-syndication-server.php b/includes/class-wp-push-syndication-server.php index c4d7bc5..78f3bbc 100644 --- a/includes/class-wp-push-syndication-server.php +++ b/includes/class-wp-push-syndication-server.php @@ -60,8 +60,7 @@ public function __construct() { add_action( 'transition_post_status', array( $this, 'save_syndicate_settings' ) ); // Use transition_post_status instead of save_post because the former is fired earlier which causes race conditions when a site group select and publish happen on the same load. add_action( 'wp_trash_post', array( $this, 'delete_content' ) ); - // adding custom time interval. - add_filter( 'cron_schedules', array( $this, 'cron_add_pull_time_interval' ) ); + // Note: cron_schedules filter is now handled by PluginBootstrapper. // firing a cron job. add_action( 'transition_post_status', array( $this, 'pre_schedule_push_content' ), 10, 3 ); @@ -1821,24 +1820,7 @@ public function current_user_can_syndicate() { return current_user_can( $syndicate_cap ); } - public function cron_add_pull_time_interval( $schedules ) { - - // Only add custom interval if syndication settings are defined. - if ( - empty( $this->push_syndicate_settings ) - || ! array_key_exists( 'pull_time_interval', $this->push_syndicate_settings ) - ) { - return $schedules; - } - - // Adds the custom time interval to the existing schedules. - $schedules['syn_pull_time_interval'] = array( - 'interval' => intval( $this->push_syndicate_settings['pull_time_interval'] ), - 'display' => esc_html__( 'Pull Time Interval', 'push-syndication' ), - ); - - return $schedules; - } + // Note: cron_add_pull_time_interval is now handled by PluginBootstrapper::on_cron_schedules(). public function pre_schedule_pull_content( $selected_sitegroups ) { From e2b2a5296919587b78aa6b38113140ec50916722 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 29 Jan 2026 11:25:11 +0000 Subject: [PATCH 15/16] feat: introduce CLI commands with integration test infrastructure CLI commands (push-post, pull-site, push-all-posts, pull-sitegroup, sites-list, sitegroups-list) are wired into the DI container via PluginBootstrapper. Each command is a thin adapter that delegates to PushService or PullService, keeping business logic in the application layer where it is already tested. The test strategy follows the pyramid from TESTING.md: 22 PHPUnit integration tests exercise commands against a real WordPress database, while Behat is reserved for CLI contract verification (argument parsing, output format). A WpCliStub replaces the real WP_CLI class during integration tests, preventing the fatal exit on error calls and capturing output for assertion. CliTestCase and WpCliOutputCapture provide the assertion helpers that keep individual test bodies concise. Application contracts (PullServiceInterface, PushServiceInterface) give the CLI layer a stable boundary to programme against, independent of transport implementation details. Co-Authored-By: Claude Opus 4.5 --- .../Contracts/PullServiceInterface.php | 42 ++ .../Contracts/PushServiceInterface.php | 46 ++ .../Services/CredentialTestingService.php | 234 ++++++ .../Infrastructure/CLI/CLIOutputHandler.php | 223 ++++++ .../CLI/ListSitegroupsCommand.php | 106 +++ .../Infrastructure/CLI/ListSitesCommand.php | 112 +++ .../Infrastructure/CLI/PullSiteCommand.php | 125 +++ .../CLI/PullSitegroupCommand.php | 201 +++++ .../CLI/PushAllPostsCommand.php | 245 ++++++ .../Infrastructure/CLI/PushPostCommand.php | 177 +++++ .../WordPress/PluginBootstrapper.php | 712 ++++++++++++++++++ tests/Behat/README.md | 122 +++ tests/Integration/CLI/CliTestCase.php | 312 ++++++++ tests/Integration/CLI/PullSiteCommandTest.php | 209 +++++ tests/Integration/CLI/PushPostCommandTest.php | 345 +++++++++ tests/Integration/CLI/WpCliOutputCapture.php | 156 ++++ tests/Integration/TestCase.php | 30 + tests/Stubs/WpCliStub.php | 169 +++++ tests/bootstrap.php | 15 + 19 files changed, 3581 insertions(+) create mode 100644 includes/Application/Contracts/PullServiceInterface.php create mode 100644 includes/Application/Contracts/PushServiceInterface.php create mode 100644 includes/Application/Services/CredentialTestingService.php create mode 100644 includes/Infrastructure/CLI/CLIOutputHandler.php create mode 100644 includes/Infrastructure/CLI/ListSitegroupsCommand.php create mode 100644 includes/Infrastructure/CLI/ListSitesCommand.php create mode 100644 includes/Infrastructure/CLI/PullSiteCommand.php create mode 100644 includes/Infrastructure/CLI/PullSitegroupCommand.php create mode 100644 includes/Infrastructure/CLI/PushAllPostsCommand.php create mode 100644 includes/Infrastructure/CLI/PushPostCommand.php create mode 100644 includes/Infrastructure/WordPress/PluginBootstrapper.php create mode 100644 tests/Behat/README.md create mode 100644 tests/Integration/CLI/CliTestCase.php create mode 100644 tests/Integration/CLI/PullSiteCommandTest.php create mode 100644 tests/Integration/CLI/PushPostCommandTest.php create mode 100644 tests/Integration/CLI/WpCliOutputCapture.php create mode 100644 tests/Integration/TestCase.php create mode 100644 tests/Stubs/WpCliStub.php diff --git a/includes/Application/Contracts/PullServiceInterface.php b/includes/Application/Contracts/PullServiceInterface.php new file mode 100644 index 0000000..3296c98 --- /dev/null +++ b/includes/Application/Contracts/PullServiceInterface.php @@ -0,0 +1,42 @@ +|null $slave_states Optional slave states array (modified by reference). + * @return PushResult The push result. + */ + public function push_to_site( int $post_id, int $site_id, ?array &$slave_states = null ): PushResult; + + /** + * Delete a post from a site. + * + * @param int $post_id The local post ID. + * @param int $site_id The site post ID. + * @return PushResult The delete result. + */ + public function delete_from_site( int $post_id, int $site_id ): PushResult; +} diff --git a/includes/Application/Services/CredentialTestingService.php b/includes/Application/Services/CredentialTestingService.php new file mode 100644 index 0000000..e2af216 --- /dev/null +++ b/includes/Application/Services/CredentialTestingService.php @@ -0,0 +1,234 @@ +, name: string}> + */ + private array $transports = array(); + + /** + * Constructor. + * + * @param TransportFactoryInterface $transport_factory Transport factory. + * @param EncryptorInterface $encryptor Encryptor. + */ + public function __construct( TransportFactoryInterface $transport_factory, EncryptorInterface $encryptor ) { + $this->transport_factory = $transport_factory; + $this->encryptor = $encryptor; + } + + /** + * Register hooks. + */ + public function register(): void { + add_action( 'wp_ajax_syn_test_credentials', array( $this, 'handle_ajax' ) ); + add_action( 'admin_init', array( $this, 'load_transports' ) ); + } + + /** + * Load available transports. + */ + public function load_transports(): void { + $this->transports = $this->transport_factory->get_available_transports(); + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $this->transports = apply_filters( 'syn_transports', $this->transports ); + } + + /** + * Handle the AJAX credential test request. + */ + public function handle_ajax(): void { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value used only for verification. + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'syn_test_credentials' ) ) { + wp_send_json_error( array( 'message' => __( 'Security check failed.', 'push-syndication' ) ) ); + } + + if ( ! $this->current_user_can_syndicate() ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'push-syndication' ) ) ); + } + + $transport_type_mode = isset( $_POST['transport_type_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['transport_type_mode'] ) ) : ''; + $parts = explode( '|', $transport_type_mode ); + $transport_type = $parts[0] ?? ''; + + if ( empty( $transport_type ) || ! isset( $this->transports[ $transport_type ] ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid transport type.', 'push-syndication' ) ) ); + } + + $site_id = isset( $_POST['site_id'] ) ? absint( $_POST['site_id'] ) : 0; + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Values sanitized in get_test_credentials(). + $credentials = $this->get_test_credentials( $transport_type, $_POST, $site_id ); + $transport = $this->create_transport_for_testing( $transport_type, $credentials ); + + if ( null === $transport ) { + wp_send_json_error( array( 'message' => __( 'Could not create transport. Please check all fields are filled.', 'push-syndication' ) ) ); + } + + try { + $result = $transport->test_connection(); + + if ( $result ) { + wp_send_json_success( array( 'message' => __( 'Connection successful! Credentials are valid.', 'push-syndication' ) ) ); + } else { + wp_send_json_error( array( 'message' => __( 'Connection failed. Please check your credentials.', 'push-syndication' ) ) ); + } + } catch ( \Exception $e ) { + /* translators: %s: error message */ + wp_send_json_error( array( 'message' => sprintf( __( 'Connection error: %s', 'push-syndication' ), $e->getMessage() ) ) ); + } + } + + /** + * Get credentials for testing, merging form data with stored values. + * + * @param string $transport_type The transport type. + * @param array $post_data The POST data from the form. + * @param int $site_id The site post ID (0 for new sites). + * @return array Merged credentials. + */ + private function get_test_credentials( string $transport_type, array $post_data, int $site_id ): array { + $credentials = $post_data; + + if ( $site_id <= 0 ) { + return $credentials; + } + + // Site URL. + if ( empty( $credentials['site_url'] ) ) { + $credentials['site_url'] = get_post_meta( $site_id, 'syn_site_url', true ); + } + + // Username. + if ( empty( $credentials['site_username'] ) ) { + $credentials['site_username'] = get_post_meta( $site_id, 'syn_site_username', true ); + } + + // Password. + if ( empty( $credentials['site_password'] ) ) { + $encrypted = get_post_meta( $site_id, 'syn_site_password', true ); + if ( ! empty( $encrypted ) ) { + $decrypted = $this->encryptor->decrypt( $encrypted ); + $credentials['site_password'] = is_string( $decrypted ) ? $decrypted : ''; + } + } + + // Token (for WordPress.com). + if ( empty( $credentials['site_token'] ) ) { + $encrypted = get_post_meta( $site_id, 'syn_site_token', true ); + if ( ! empty( $encrypted ) ) { + $decrypted = $this->encryptor->decrypt( $encrypted ); + $credentials['site_token'] = is_string( $decrypted ) ? $decrypted : ''; + } + } + + // Blog ID (for WordPress.com). + if ( empty( $credentials['blog_id'] ) ) { + $credentials['blog_id'] = get_post_meta( $site_id, 'syn_site_id', true ); + } + + // Feed URL (for RSS). + if ( empty( $credentials['feed_url'] ) ) { + $credentials['feed_url'] = get_post_meta( $site_id, 'syn_feed_url', true ); + } + + return $credentials; + } + + /** + * Create a transport instance for credential testing. + * + * @param string $transport_type The transport type ID. + * @param array $post_data The POST data with credentials. + * @return TransportInterface|null The transport or null. + */ + private function create_transport_for_testing( string $transport_type, array $post_data ): ?TransportInterface { + $site_url = isset( $post_data['site_url'] ) ? esc_url_raw( (string) $post_data['site_url'] ) : ''; + $username = isset( $post_data['site_username'] ) ? sanitize_text_field( (string) $post_data['site_username'] ) : ''; + $password = isset( $post_data['site_password'] ) ? (string) $post_data['site_password'] : ''; + + switch ( $transport_type ) { + case 'WP_REST_API': + if ( empty( $site_url ) || empty( $username ) || empty( $password ) ) { + return null; + } + return new WordPressRestTransport( 0, $site_url, $username, $password ); + + case 'WP_XMLRPC': + if ( empty( $site_url ) || empty( $username ) || empty( $password ) ) { + return null; + } + return new XMLRPCTransport( 0, $site_url, $username, $password ); + + case 'WP_REST': + $token = isset( $post_data['site_token'] ) ? (string) $post_data['site_token'] : $password; + $blog_id = isset( $post_data['site_id'] ) ? sanitize_text_field( (string) $post_data['site_id'] ) : ''; + if ( empty( $token ) || empty( $blog_id ) ) { + return null; + } + return new WordPressComTransport( 0, $token, $blog_id ); + + case 'WP_RSS': + $feed_url = isset( $post_data['feed_url'] ) ? esc_url_raw( (string) $post_data['feed_url'] ) : $site_url; + if ( empty( $feed_url ) ) { + return null; + } + return new RSSFeedTransport( 0, $feed_url, 'post', 'draft', 'closed', 'closed', false ); + + default: + return null; + } + } + + /** + * Check if the current user can syndicate. + * + * @return bool True if user can syndicate. + */ + private function current_user_can_syndicate(): bool { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $capability = apply_filters( 'syn_syndicate_cap', 'manage_options' ); + + return current_user_can( $capability ); + } +} diff --git a/includes/Infrastructure/CLI/CLIOutputHandler.php b/includes/Infrastructure/CLI/CLIOutputHandler.php new file mode 100644 index 0000000..8720389 --- /dev/null +++ b/includes/Infrastructure/CLI/CLIOutputHandler.php @@ -0,0 +1,223 @@ +verbosity_enabled ) { + return; + } + + $this->verbosity_enabled = true; + + add_filter( + 'syn_pre_push_post_sites', + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Hook signature requires all parameters. + static function ( $sites, $post_id, $slave_states ) { + WP_CLI::log( sprintf( 'Processing post_id #%d (%s)', $post_id, get_the_title( $post_id ) ) ); + WP_CLI::log( + sprintf( + '-- pushing to %s sites and deleting from %s sites', + number_format( count( $sites['selected_sites'] ?? array() ) ), + number_format( count( $sites['removed_sites'] ?? array() ) ) + ) + ); + + return $sites; + }, + 10, + 3 + ); + + add_action( + 'syn_post_push_new_post', + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Hook signature requires all parameters. + static function ( $result, $post_id, $site, $transport_type, $client, $info ): void { + WP_CLI::log( sprintf( '-- Added remote post #%d (%s)', $post_id, $site->post_title ?? 'Unknown' ) ); + }, + 10, + 6 + ); + + add_action( + 'syn_post_push_edit_post', + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Hook signature requires all parameters. + static function ( $result, $post_id, $site, $transport_type, $client, $info ): void { + WP_CLI::log( sprintf( '-- Updated remote post #%d (%s)', $post_id, $site->post_title ?? 'Unknown' ) ); + }, + 10, + 6 + ); + } + + /** + * Enable verbose output for pull operations. + * + * Hooks into syndication filters and actions to output progress information to the CLI. + */ + protected function enable_pull_verbosity(): void { + if ( $this->verbosity_enabled ) { + return; + } + + $this->verbosity_enabled = true; + + add_filter( + 'syn_pre_pull_posts', + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Hook signature requires all parameters. + static function ( $posts, $site, $client ) { + WP_CLI::log( sprintf( 'Processing feed %s (%d)', $site->post_title ?? 'Unknown', $site->ID ?? 0 ) ); + WP_CLI::log( sprintf( '-- found %s posts', count( $posts ) ) ); + + return $posts; + }, + 10, + 3 + ); + + add_action( + 'syn_post_pull_new_post', + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Hook signature requires all parameters. + static function ( $result, $post, $site, $transport_type, $client ): void { + WP_CLI::log( sprintf( '-- New post #%d (%s)', $result, $post['post_guid'] ?? 'Unknown' ) ); + }, + 10, + 5 + ); + + add_action( + 'syn_post_pull_edit_post', + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Hook signature requires all parameters. + static function ( $result, $post, $site, $transport_type, $client ): void { + WP_CLI::log( sprintf( '-- Updated post #%d (%s)', $result, $post['post_guid'] ?? 'Unknown' ) ); + }, + 10, + 5 + ); + } + + /** + * Clear object caches to reduce memory usage during long-running operations. + * + * Resets WP query cache and object cache to prevent memory exhaustion + * when processing large numbers of posts. + */ + protected function stop_the_insanity(): void { + global $wpdb, $wp_object_cache; + + $wpdb->queries = array(); + + if ( ! is_object( $wp_object_cache ) ) { + return; + } + + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- WordPress core object property. + if ( property_exists( $wp_object_cache, 'group_ops' ) ) { + $wp_object_cache->group_ops = array(); + } + + if ( property_exists( $wp_object_cache, 'stats' ) ) { + $wp_object_cache->stats = array(); + } + + if ( property_exists( $wp_object_cache, 'memcache_debug' ) ) { + $wp_object_cache->memcache_debug = array(); + } + + if ( property_exists( $wp_object_cache, 'cache' ) ) { + $wp_object_cache->cache = array(); + } + // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + + if ( is_callable( array( $wp_object_cache, '__remoteset' ) ) ) { + $wp_object_cache->__remoteset(); + } + } + + /** + * Output a PushResult to the CLI. + * + * @param PushResult $result The push result. + */ + protected function output_push_result( PushResult $result ): void { + $site = get_post( $result->site_id ); + $name = $site->post_title ?? "Site #{$result->site_id}"; + + if ( $result->is_success() ) { + WP_CLI::success( sprintf( '%s: %s (remote ID: %d)', $name, $result->action, $result->remote_id ) ); + } elseif ( $result->is_skipped() ) { + WP_CLI::warning( sprintf( '%s: Skipped - %s', $name, $result->message ) ); + } else { + WP_CLI::warning( sprintf( '%s: Failed - %s (%s)', $name, $result->message, $result->error_code ) ); + } + } + + /** + * Output a PullResult to the CLI. + * + * @param PullResult $result The pull result. + */ + protected function output_pull_result( PullResult $result ): void { + $site = get_post( $result->site_id ); + $name = $site->post_title ?? "Site #{$result->site_id}"; + + if ( $result->is_success() ) { + WP_CLI::success( + sprintf( + '%s: Created %d, updated %d posts', + $name, + $result->created, + $result->updated + ) + ); + } elseif ( $result->is_skipped() ) { + WP_CLI::warning( sprintf( '%s: Skipped - %s', $name, $result->message ) ); + } elseif ( $result->is_partial() ) { + WP_CLI::warning( + sprintf( + '%s: Partial - Created %d, updated %d, skipped %d, errors: %d', + $name, + $result->created, + $result->updated, + $result->skipped, + count( $result->errors ) + ) + ); + foreach ( $result->errors as $error ) { + WP_CLI::warning( " - {$error}" ); + } + } else { + WP_CLI::warning( sprintf( '%s: Failed - %s (%s)', $name, $result->message, $result->error_code ) ); + } + } +} diff --git a/includes/Infrastructure/CLI/ListSitegroupsCommand.php b/includes/Infrastructure/CLI/ListSitegroupsCommand.php new file mode 100644 index 0000000..60c1a1e --- /dev/null +++ b/includes/Infrastructure/CLI/ListSitegroupsCommand.php @@ -0,0 +1,106 @@ +] + * : Output format. Options: table, csv, json, yaml, ids. Default: table. + * + * ## EXAMPLES + * + * # List all sitegroups as a table + * $ wp syndication sitegroups-list + * + * # List all sitegroups as JSON + * $ wp syndication sitegroups-list --format=json + * + * # List only sitegroup slugs (one per line) + * $ wp syndication sitegroups-list --format=ids + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $assoc_args = wp_parse_args( + $assoc_args, + array( + 'format' => 'table', + ) + ); + + $format = sanitize_key( $assoc_args['format'] ); + + // Get all sitegroup terms. + $terms = get_terms( + array( + 'taxonomy' => self::TAXONOMY, + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + WP_CLI::warning( __( 'No sitegroups found.', 'push-syndication' ) ); + return; + } + + // Handle 'ids' format specially (output slugs one per line, like 2.1 branch). + if ( 'ids' === $format ) { + $slugs = array_map( + fn( $term ) => $term->slug, + $terms + ); + WP_CLI::line( implode( PHP_EOL, $slugs ) ); + return; + } + + // Format the data for output. + $items = array(); + foreach ( $terms as $term ) { + $items[] = array( + 'term_id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'count' => $term->count, + ); + } + + Utils\format_items( + $format, + $items, + array( 'term_id', 'name', 'slug', 'count' ) + ); + } +} diff --git a/includes/Infrastructure/CLI/ListSitesCommand.php b/includes/Infrastructure/CLI/ListSitesCommand.php new file mode 100644 index 0000000..0d4685a --- /dev/null +++ b/includes/Infrastructure/CLI/ListSitesCommand.php @@ -0,0 +1,112 @@ +site_repository = $site_repository; + } + + /** + * List all syndication sites. + * + * ## OPTIONS + * + * [--format=] + * : Output format. Options: table, csv, json, yaml. Default: table. + * + * ## EXAMPLES + * + * # List all sites as a table + * $ wp syndication sites-list + * + * # List all sites as JSON + * $ wp syndication sites-list --format=json + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $assoc_args = wp_parse_args( + $assoc_args, + array( + 'format' => 'table', + ) + ); + + $format = sanitize_key( $assoc_args['format'] ); + + // Query all syn_site posts. + $sites = get_posts( + array( + 'post_type' => 'syn_site', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + if ( empty( $sites ) ) { + WP_CLI::warning( __( 'No syndication sites found.', 'push-syndication' ) ); + return; + } + + // Format the data for output. + $items = array(); + foreach ( $sites as $site ) { + $enabled = get_post_meta( $site->ID, 'syn_site_enabled', true ); + $transport_type = get_post_meta( $site->ID, 'syn_transport_type', true ); + $url = get_post_meta( $site->ID, 'syn_site_url', true ); + + $items[] = array( + 'ID' => $site->ID, + 'post_title' => $site->post_title, + 'post_name' => $site->post_name, + 'enabled' => 'on' === $enabled ? 'yes' : 'no', + 'transport_type' => ! empty( $transport_type ) ? $transport_type : 'N/A', + 'url' => ! empty( $url ) ? $url : 'N/A', + ); + } + + Utils\format_items( + $format, + $items, + array( 'ID', 'post_title', 'post_name', 'enabled', 'transport_type', 'url' ) + ); + } +} diff --git a/includes/Infrastructure/CLI/PullSiteCommand.php b/includes/Infrastructure/CLI/PullSiteCommand.php new file mode 100644 index 0000000..6a5c31e --- /dev/null +++ b/includes/Infrastructure/CLI/PullSiteCommand.php @@ -0,0 +1,125 @@ +pull_service = $pull_service; + $this->site_repository = $site_repository; + } + + /** + * Pull content from a single syndication site. + * + * ## OPTIONS + * + * --site_id= + * : The ID of the syndication site to pull from. + * + * [--verbose] + * : Enable verbose output showing pull progress. + * + * ## EXAMPLES + * + * # Pull from a site + * $ wp syndication pull-site --site_id=456 + * + * # Pull with verbose output + * $ wp syndication pull-site --site_id=456 --verbose + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $assoc_args = wp_parse_args( + $assoc_args, + array( + 'site_id' => 0, + 'verbose' => false, + ) + ); + + $site_id = (int) $assoc_args['site_id']; + $verbose = (bool) $assoc_args['verbose']; + + // Validate site exists. + $site_config = $this->site_repository->get( $site_id ); + + if ( null === $site_config ) { + WP_CLI::error( __( 'Please select a valid site.', 'push-syndication' ) ); + } + + $site = get_post( $site_id ); + + if ( ! $site || 'syn_site' !== $site->post_type ) { + WP_CLI::error( __( 'Please select a valid site.', 'push-syndication' ) ); + } + + // Enable verbose output if requested. + if ( $verbose ) { + $this->enable_pull_verbosity(); + } + + WP_CLI::log( sprintf( 'Pulling content from %s...', $site->post_title ) ); + + // Pull from the site. + $result = $this->pull_service->pull_from_site( $site_id ); + + // Output result. + $this->output_pull_result( $result ); + + // Exit with appropriate status. + if ( $result->is_failure() ) { + WP_CLI::error( 'Pull operation failed.' ); + } elseif ( $result->is_partial() ) { + WP_CLI::warning( 'Pull completed with some errors.' ); + } + } +} diff --git a/includes/Infrastructure/CLI/PullSitegroupCommand.php b/includes/Infrastructure/CLI/PullSitegroupCommand.php new file mode 100644 index 0000000..3568868 --- /dev/null +++ b/includes/Infrastructure/CLI/PullSitegroupCommand.php @@ -0,0 +1,201 @@ +pull_service = $pull_service; + $this->site_repository = $site_repository; + } + + /** + * Pull content from all sites in a sitegroup. + * + * ## OPTIONS + * + * --sitegroup= + * : The sitegroup slug to pull from. + * + * [--verbose] + * : Enable verbose output showing pull progress. + * + * ## EXAMPLES + * + * # Pull from a sitegroup + * $ wp syndication pull-sitegroup --sitegroup=news-feeds + * + * # Pull with verbose output + * $ wp syndication pull-sitegroup --sitegroup=news-feeds --verbose + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $assoc_args = wp_parse_args( + $assoc_args, + array( + 'sitegroup' => '', + 'verbose' => false, + ) + ); + + $sitegroup_slug = sanitize_key( $assoc_args['sitegroup'] ); + $verbose = (bool) $assoc_args['verbose']; + + if ( empty( $sitegroup_slug ) ) { + WP_CLI::error( __( 'Please specify a valid sitegroup', 'push-syndication' ) ); + } + + // Look up the sitegroup term by slug. + $term = get_term_by( 'slug', $sitegroup_slug, self::TAXONOMY ); + + if ( ! $term || is_wp_error( $term ) ) { + WP_CLI::error( + sprintf( + // translators: %s: sitegroup slug. + __( 'Sitegroup "%s" not found.', 'push-syndication' ), + $sitegroup_slug + ) + ); + } + + // Get sites in this sitegroup. + $sites = $this->site_repository->get_by_group( $term->term_id ); + + if ( empty( $sites ) ) { + WP_CLI::error( + sprintf( + // translators: %s: sitegroup slug. + __( 'No sites found in sitegroup "%s".', 'push-syndication' ), + $sitegroup_slug + ) + ); + } + + // Enable verbose output if requested. + if ( $verbose ) { + $this->enable_pull_verbosity(); + } + + $site_count = count( $sites ); + WP_CLI::log( + sprintf( + 'Pulling content from %d site(s) in sitegroup "%s"...', + $site_count, + $sitegroup_slug + ) + ); + + // Extract site IDs for enabled sites. + $site_ids = array(); + foreach ( $sites as $site ) { + if ( $site->is_enabled() ) { + $site_ids[] = $site->get_site_id(); + } + } + + if ( empty( $site_ids ) ) { + WP_CLI::warning( __( 'No enabled sites found in sitegroup.', 'push-syndication' ) ); + return; + } + + // Pull from all sites. + $results = $this->pull_service->pull_from_sites( $site_ids ); + + // Output results. + $success_count = 0; + $failure_count = 0; + $total_created = 0; + $total_updated = 0; + + foreach ( $results as $result ) { + if ( $verbose ) { + $this->output_pull_result( $result ); + } + + if ( $result->is_success() || $result->is_partial() ) { + ++$success_count; + $total_created += $result->created; + $total_updated += $result->updated; + } elseif ( $result->is_failure() ) { + ++$failure_count; + } + } + + // Clear memory after processing. + $this->stop_the_insanity(); + + // Summary. + WP_CLI::log( '' ); + WP_CLI::log( '=== Summary ===' ); + WP_CLI::log( sprintf( 'Sites processed: %d', count( $results ) ) ); + WP_CLI::log( sprintf( 'Posts created: %d', $total_created ) ); + WP_CLI::log( sprintf( 'Posts updated: %d', $total_updated ) ); + + if ( $failure_count > 0 ) { + WP_CLI::warning( + sprintf( + 'Pull completed with %d success, %d failures.', + $success_count, + $failure_count + ) + ); + } else { + WP_CLI::success( + sprintf( 'Successfully pulled from %d site(s).', $success_count ) + ); + } + } +} diff --git a/includes/Infrastructure/CLI/PushAllPostsCommand.php b/includes/Infrastructure/CLI/PushAllPostsCommand.php new file mode 100644 index 0000000..5e54d41 --- /dev/null +++ b/includes/Infrastructure/CLI/PushAllPostsCommand.php @@ -0,0 +1,245 @@ +push_service = $push_service; + $this->site_repository = $site_repository; + } + + /** + * Push all posts of a given type to syndicated sites. + * + * ## OPTIONS + * + * [--post_type=] + * : The post type to push. Default: post. + * + * [--paged=] + * : Starting page number. Default: 1. + * + * [--verbose] + * : Enable verbose output showing push progress. + * + * ## EXAMPLES + * + * # Push all posts + * $ wp syndication push-all-posts + * + * # Push all pages starting from page 3 + * $ wp syndication push-all-posts --post_type=page --paged=3 + * + * # Push with verbose output + * $ wp syndication push-all-posts --verbose + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $assoc_args = wp_parse_args( + $assoc_args, + array( + 'post_type' => 'post', + 'paged' => 1, + 'verbose' => false, + ) + ); + + $post_type = sanitize_key( $assoc_args['post_type'] ); + $paged = max( 1, (int) $assoc_args['paged'] ); + $verbose = (bool) $assoc_args['verbose']; + + // Enable verbose output if requested. + if ( $verbose ) { + $this->enable_push_verbosity(); + } + + $query_args = array( + 'post_type' => $post_type, + 'posts_per_page' => self::POSTS_PER_PAGE, + 'paged' => $paged, + 'post_status' => 'publish', + ); + + $query = new WP_Query( $query_args ); + $total_success = 0; + $total_failures = 0; + $total_posts = 0; + + while ( $query->post_count > 0 ) { + WP_CLI::log( sprintf( 'Processing page %d (%d posts)...', $paged, $query->post_count ) ); + + foreach ( $query->posts as $post ) { + ++$total_posts; + + if ( $verbose ) { + WP_CLI::log( sprintf( 'Processing post %d (%s)', $post->ID, $post->post_title ) ); + } + + $result = $this->push_single_post( $post->ID, $verbose ); + + $total_success += $result['success']; + $total_failures += $result['failures']; + } + + // Clear memory between batches. + $this->stop_the_insanity(); + + // Sleep between batches to avoid overwhelming remote servers. + sleep( self::BATCH_SLEEP ); + + // Next page. + ++$paged; + $query_args['paged'] = $paged; + $query = new WP_Query( $query_args ); + } + + // Final summary. + WP_CLI::log( '' ); + WP_CLI::log( '=== Summary ===' ); + WP_CLI::log( sprintf( 'Total posts processed: %d', $total_posts ) ); + WP_CLI::log( sprintf( 'Successful pushes: %d', $total_success ) ); + WP_CLI::log( sprintf( 'Failed pushes: %d', $total_failures ) ); + + if ( $total_failures > 0 ) { + WP_CLI::warning( 'Completed with some failures.' ); + } else { + WP_CLI::success( 'All posts pushed successfully.' ); + } + } + + /** + * Push a single post and return counts. + * + * @param int $post_id The post ID. + * @param bool $verbose Whether to output verbose information. + * @return array{success: int, failures: int} + */ + private function push_single_post( int $post_id, bool $verbose ): array { + $site_ids = $this->get_sites_for_post( $post_id ); + + if ( empty( $site_ids ) ) { + if ( $verbose ) { + WP_CLI::log( ' -- No sites configured for this post' ); + } + return array( + 'success' => 0, + 'failures' => 0, + ); + } + + $results = $this->push_service->push_to_sites( $post_id, $site_ids ); + $success = 0; + $failures = 0; + + foreach ( $results as $result ) { + if ( $verbose ) { + $this->output_push_result( $result ); + } + + if ( $result->is_success() ) { + ++$success; + } else { + ++$failures; + } + } + + return array( + 'success' => $success, + 'failures' => $failures, + ); + } + + /** + * Get site IDs for a post based on its selected sitegroups. + * + * @param int $post_id The post ID. + * @return array Array of site IDs. + */ + private function get_sites_for_post( int $post_id ): array { + $selected_sitegroups = get_post_meta( $post_id, '_syn_selected_sitegroups', true ); + $selected_sitegroups = ! empty( $selected_sitegroups ) && is_array( $selected_sitegroups ) + ? $selected_sitegroups + : array(); + + if ( empty( $selected_sitegroups ) ) { + return array(); + } + + $site_ids = array(); + + foreach ( $selected_sitegroups as $sitegroup_id ) { + $sites = $this->site_repository->get_by_group( (int) $sitegroup_id ); + + foreach ( $sites as $site ) { + if ( $site->is_enabled() ) { + $site_ids[] = $site->get_site_id(); + } + } + } + + return array_unique( $site_ids ); + } +} diff --git a/includes/Infrastructure/CLI/PushPostCommand.php b/includes/Infrastructure/CLI/PushPostCommand.php new file mode 100644 index 0000000..b2bc388 --- /dev/null +++ b/includes/Infrastructure/CLI/PushPostCommand.php @@ -0,0 +1,177 @@ +push_service = $push_service; + $this->site_repository = $site_repository; + } + + /** + * Push a single post to syndicated sites. + * + * ## OPTIONS + * + * --post_id= + * : The ID of the post to push. + * + * [--verbose] + * : Enable verbose output showing push progress. + * + * ## EXAMPLES + * + * # Push a post + * $ wp syndication push-post --post_id=123 + * + * # Push with verbose output + * $ wp syndication push-post --post_id=123 --verbose + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $assoc_args = wp_parse_args( + $assoc_args, + array( + 'post_id' => 0, + 'verbose' => false, + ) + ); + + $post_id = (int) $assoc_args['post_id']; + $verbose = (bool) $assoc_args['verbose']; + + // Validate post exists. + $post = get_post( $post_id ); + if ( ! $post ) { + WP_CLI::error( __( 'Invalid post_id', 'push-syndication' ) ); + } + + // Enable verbose output if requested. + if ( $verbose ) { + $this->enable_push_verbosity(); + } + + // Get sites for this post. + $site_ids = $this->get_sites_for_post( $post_id ); + + if ( empty( $site_ids ) ) { + WP_CLI::error( __( 'Post has no selected sitegroups / sites', 'push-syndication' ) ); + } + + WP_CLI::log( sprintf( 'Pushing post #%d to %d site(s)...', $post_id, count( $site_ids ) ) ); + + // Push to all sites. + $results = $this->push_service->push_to_sites( $post_id, $site_ids ); + + // Output results. + $success_count = 0; + $failure_count = 0; + + foreach ( $results as $result ) { + if ( $verbose ) { + $this->output_push_result( $result ); + } + + if ( $result->is_success() ) { + ++$success_count; + } else { + ++$failure_count; + } + } + + // Summary. + if ( $failure_count > 0 ) { + WP_CLI::warning( + sprintf( + 'Push completed with %d success, %d failures.', + $success_count, + $failure_count + ) + ); + } else { + WP_CLI::success( + sprintf( 'Successfully pushed to %d site(s).', $success_count ) + ); + } + } + + /** + * Get site IDs for a post based on its selected sitegroups. + * + * @param int $post_id The post ID. + * @return array Array of site IDs. + */ + private function get_sites_for_post( int $post_id ): array { + $selected_sitegroups = get_post_meta( $post_id, '_syn_selected_sitegroups', true ); + $selected_sitegroups = ! empty( $selected_sitegroups ) && is_array( $selected_sitegroups ) + ? $selected_sitegroups + : array(); + + if ( empty( $selected_sitegroups ) ) { + return array(); + } + + $site_ids = array(); + + foreach ( $selected_sitegroups as $sitegroup_id ) { + $sites = $this->site_repository->get_by_group( (int) $sitegroup_id ); + + foreach ( $sites as $site ) { + if ( $site->is_enabled() ) { + $site_ids[] = $site->get_site_id(); + } + } + } + + return array_unique( $site_ids ); + } +} diff --git a/includes/Infrastructure/WordPress/PluginBootstrapper.php b/includes/Infrastructure/WordPress/PluginBootstrapper.php new file mode 100644 index 0000000..6f7d1b7 --- /dev/null +++ b/includes/Infrastructure/WordPress/PluginBootstrapper.php @@ -0,0 +1,712 @@ +container = $container; + } + + /** + * Initialise the plugin. + */ + public function init(): void { + if ( $this->initialised ) { + return; + } + + $this->register_services(); + $this->register_hooks(); + $this->register_admin_services(); + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $this->register_cli_commands(); + } + + $this->initialised = true; + } + + /** + * Register additional services in the container. + */ + private function register_services(): void { + // Register PushService - both interface and concrete class. + $push_service_factory = function ( Container $container ): PushService { + $factory = $container->get( TransportFactoryInterface::class ); + \assert( $factory instanceof TransportFactoryInterface ); + return new PushService( $factory ); + }; + $this->container->register( PushService::class, $push_service_factory ); + $this->container->register( PushServiceInterface::class, $push_service_factory ); + + // Register PullService - both interface and concrete class. + $pull_service_factory = function ( Container $container ): PullService { + $factory = $container->get( TransportFactoryInterface::class ); + \assert( $factory instanceof TransportFactoryInterface ); + return new PullService( $factory ); + }; + $this->container->register( PullService::class, $pull_service_factory ); + $this->container->register( PullServiceInterface::class, $pull_service_factory ); + } + + /** + * Register admin services. + */ + private function register_admin_services(): void { + if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { + return; + } + + // Site list table customisation. + $site_list = $this->container->get( SiteListTable::class ); + \assert( $site_list instanceof SiteListTable ); + $site_list->register(); + + // Settings page. + $settings_page = $this->container->get( SettingsPage::class ); + \assert( $settings_page instanceof SettingsPage ); + $settings_page->register(); + + // Site metaboxes - stored for use in PostTypeRegistrar callback. + $site_metaboxes = $this->container->get( SiteMetaboxes::class ); + \assert( $site_metaboxes instanceof SiteMetaboxes ); + $site_metaboxes->register(); + + // Post syndication metabox. + $post_metabox = $this->container->get( PostSyndicationMetabox::class ); + \assert( $post_metabox instanceof PostSyndicationMetabox ); + $post_metabox->register(); + + // Admin assets. + $admin_assets = $this->container->get( AdminAssets::class ); + \assert( $admin_assets instanceof AdminAssets ); + $admin_assets->register(); + + // Admin messages. + $admin_messages = $this->container->get( AdminMessages::class ); + \assert( $admin_messages instanceof AdminMessages ); + $admin_messages->register(); + + // Credential testing service. + $credential_testing = $this->container->get( CredentialTestingService::class ); + \assert( $credential_testing instanceof CredentialTestingService ); + $credential_testing->register(); + } + + /** + * Register WordPress hooks. + */ + private function register_hooks(): void { + // Initialisation hooks. + add_action( 'init', array( $this, 'on_init' ) ); + add_action( 'admin_init', array( $this, 'on_admin_init' ) ); + + // Content syndication hooks. + add_action( 'transition_post_status', array( $this, 'on_transition_post_status' ), 10, 3 ); + add_action( 'wp_trash_post', array( $this, 'on_trash_post' ) ); + + // Cron hooks. + add_filter( 'cron_schedules', array( $this, 'on_cron_schedules' ) ); + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + add_action( 'syn_schedule_push_content', array( $this, 'on_schedule_push_content' ), 10, 2 ); + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + add_action( 'syn_push_content', array( $this, 'on_push_content' ) ); + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + add_action( 'syn_pull_content', array( $this, 'on_pull_content' ) ); + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + add_action( 'syn_refresh_pull_jobs', array( $this, 'on_refresh_pull_jobs' ) ); + + // Site management hooks. + add_action( 'save_post', array( $this, 'on_save_post' ), 10, 3 ); + add_action( 'delete_post', array( $this, 'on_delete_post' ) ); + add_action( 'create_term', array( $this, 'on_create_term' ), 10, 3 ); + add_action( 'delete_term', array( $this, 'on_delete_term' ), 10, 3 ); + } + + /** + * Register WP-CLI commands. + */ + private function register_cli_commands(): void { + $push_service = $this->container->get( PushServiceInterface::class ); + \assert( $push_service instanceof PushServiceInterface ); + + $pull_service = $this->container->get( PullServiceInterface::class ); + \assert( $pull_service instanceof PullServiceInterface ); + + $site_repository = $this->container->get( SiteRepositoryInterface::class ); + \assert( $site_repository instanceof SiteRepositoryInterface ); + + \WP_CLI::add_command( + 'syndication push-post', + new PushPostCommand( $push_service, $site_repository ) + ); + + \WP_CLI::add_command( + 'syndication push-all-posts', + new PushAllPostsCommand( $push_service, $site_repository ) + ); + + \WP_CLI::add_command( + 'syndication pull-site', + new PullSiteCommand( $pull_service, $site_repository ) + ); + + \WP_CLI::add_command( + 'syndication pull-sitegroup', + new PullSitegroupCommand( $pull_service, $site_repository ) + ); + + \WP_CLI::add_command( + 'syndication sites-list', + new ListSitesCommand( $site_repository ) + ); + + \WP_CLI::add_command( + 'syndication sitegroups-list', + new ListSitegroupsCommand() + ); + } + + /** + * Handle init action. + */ + public function on_init(): void { + $registrar = $this->container->get( PostTypeRegistrar::class ); + \assert( $registrar instanceof PostTypeRegistrar ); + $registrar->register(); + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + do_action( 'syn_after_init_server' ); + } + + /** + * Handle admin_init action. + */ + public function on_admin_init(): void { + // Placeholder for admin setup. + } + + /** + * Handle transition_post_status action. + * + * @param string $new_status New post status. + * @param string $old_status Old post status. + * @param \WP_Post $post Post object. + */ + public function on_transition_post_status( string $new_status, string $old_status, \WP_Post $post ): void { + unset( $new_status, $old_status ); + + if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification happens here. + if ( ! isset( $_POST['syndicate_noncename'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value used only for verification. + if ( ! wp_verify_nonce( $_POST['syndicate_noncename'], 'syndicate_nonce' ) ) { + return; + } + + if ( ! $this->current_user_can_syndicate() ) { + return; + } + + $this->save_syndicate_settings( $post->ID ); + + $sites = $this->get_sites_by_post_id( $post->ID ); + + if ( empty( $sites['selected_sites'] ) && empty( $sites['removed_sites'] ) ) { + return; + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + do_action( 'syn_schedule_push_content', $post->ID, $sites ); + } + + /** + * Save syndicate settings for a post. + * + * @param int $post_id The post ID. + */ + private function save_syndicate_settings( int $post_id ): void { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in caller. + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Array values sanitized with sanitize_key. + $selected_sitegroups = ! empty( $_POST['selected_sitegroups'] ) + ? array_map( 'sanitize_key', (array) $_POST['selected_sitegroups'] ) + : array(); + // phpcs:enable + + update_post_meta( $post_id, '_syn_selected_sitegroups', $selected_sitegroups ); + + if ( '' === get_post_meta( $post_id, 'post_uniqueid', true ) ) { + update_post_meta( $post_id, 'post_uniqueid', uniqid() ); + } + } + + /** + * Get sites for syndication by post ID. + * + * @param int $post_id The post ID. + * @return array{post_ID: int, selected_sites: array, removed_sites: array} Sites data. + */ + private function get_sites_by_post_id( int $post_id ): array { + $selected_sitegroups = get_post_meta( $post_id, '_syn_selected_sitegroups', true ); + $selected_sitegroups = is_array( $selected_sitegroups ) ? $selected_sitegroups : array(); + + $old_sitegroups = get_post_meta( $post_id, '_syn_old_sitegroups', true ); + $old_sitegroups = is_array( $old_sitegroups ) ? $old_sitegroups : array(); + + $removed_sitegroups = array_diff( $old_sitegroups, $selected_sitegroups ); + + $data = array( + 'post_ID' => $post_id, + 'selected_sites' => array(), + 'removed_sites' => array(), + ); + + $repository = $this->container->get( SiteRepositoryInterface::class ); + \assert( $repository instanceof SiteRepositoryInterface ); + + foreach ( $selected_sitegroups as $sitegroup ) { + $term = get_term_by( 'slug', $sitegroup, 'syn_sitegroup' ); + if ( ! $term instanceof \WP_Term ) { + continue; + } + + $configs = $repository->get_by_group( $term->term_id ); + foreach ( $configs as $config ) { + if ( $config->is_enabled() ) { + $data['selected_sites'][] = $config->get_site_id(); + } + } + } + + foreach ( $removed_sitegroups as $sitegroup ) { + $term = get_term_by( 'slug', $sitegroup, 'syn_sitegroup' ); + if ( ! $term instanceof \WP_Term ) { + continue; + } + + $configs = $repository->get_by_group( $term->term_id ); + foreach ( $configs as $config ) { + if ( $config->is_enabled() ) { + $data['removed_sites'][] = $config->get_site_id(); + } + } + } + + update_post_meta( $post_id, '_syn_old_sitegroups', $selected_sitegroups ); + + return $data; + } + + /** + * Check if the current user can syndicate. + * + * @return bool True if user can syndicate. + */ + private function current_user_can_syndicate(): bool { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + $capability = apply_filters( 'syn_syndicate_cap', 'manage_options' ); + + return current_user_can( $capability ); + } + + /** + * Handle wp_trash_post action. + * + * @param int $post_id Post ID being trashed. + */ + public function on_trash_post( int $post_id ): void { + $settings = get_option( 'push_syndicate_settings' ); + if ( empty( $settings['delete_pushed_posts'] ) ) { + return; + } + + $slave_posts = $this->get_slave_posts( $post_id ); + if ( empty( $slave_posts ) ) { + return; + } + + $push_service = $this->container->get( PushService::class ); + \assert( $push_service instanceof PushService ); + + $delete_errors = get_option( 'syn_delete_error_sites', array() ); + + foreach ( $slave_posts as $site_id => $remote_id ) { + if ( 'on' !== get_post_meta( $site_id, 'syn_site_enabled', true ) ) { + continue; + } + + $result = $push_service->delete_from_site( $post_id, $site_id ); + + if ( ! $result->is_success() ) { + $delete_errors[ $site_id ] = array( $remote_id ); + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name. + do_action( 'syn_post_push_delete_post', $result->is_success(), $remote_id, $post_id, $site_id, '', null ); + } + + update_option( 'syn_delete_error_sites', $delete_errors ); + } + + /** + * Get slave posts (remote posts that were syndicated). + * + * @param int $post_id The local post ID. + * @return array Array of site_id => remote_id pairs. + */ + private function get_slave_posts( int $post_id ): array { + $slave_post_states = get_post_meta( $post_id, '_syn_slave_post_states', true ); + if ( empty( $slave_post_states ) || ! is_array( $slave_post_states ) ) { + return array(); + } + + $slave_posts = array(); + + if ( ! empty( $slave_post_states['success'] ) && is_array( $slave_post_states['success'] ) ) { + foreach ( $slave_post_states['success'] as $site_id => $remote_id ) { + if ( is_numeric( $remote_id ) && $remote_id > 0 ) { + $slave_posts[ (int) $site_id ] = (int) $remote_id; + } + } + } + + return $slave_posts; + } + + /** + * Handle cron_schedules filter. + * + * @param array $schedules Existing schedules. + * @return array Modified schedules. + */ + public function on_cron_schedules( array $schedules ): array { + $settings = get_option( 'push_syndicate_settings' ); + $pull_time_interval = $settings['pull_time_interval'] ?? 3600; + + $schedules['syn_pull_time_interval'] = array( + 'interval' => (int) $pull_time_interval, + 'display' => __( 'Pull Time Interval', 'push-syndication' ), + ); + + return $schedules; + } + + /** + * Handle syn_schedule_push_content action. + * + * @param int $post_id Post ID to push. + * @param array{post_ID: int, selected_sites: array, removed_sites: array} $sites Sites data. + */ + public function on_schedule_push_content( int $post_id, array $sites ): void { + unset( $post_id ); + + wp_schedule_single_event( + time() - 1, + 'syn_push_content', + array( $sites ) + ); + + if ( function_exists( 'spawn_cron' ) ) { + spawn_cron(); + } + } + + /** + * Handle syn_push_content cron action. + * + * @param array{post_ID: int, selected_sites: array, removed_sites: array} $sites Sites data. + */ + public function on_push_content( array $sites ): void { + $post_id = $sites['post_ID'] ?? 0; + + if ( 0 === $post_id ) { + return; + } + + $push_service = $this->container->get( PushService::class ); + \assert( $push_service instanceof PushService ); + + $selected_site_ids = $this->extract_site_ids( $sites['selected_sites'] ?? array() ); + if ( ! empty( $selected_site_ids ) ) { + $push_service->push_to_sites( $post_id, $selected_site_ids ); + } + + $removed_site_ids = $this->extract_site_ids( $sites['removed_sites'] ?? array() ); + foreach ( $removed_site_ids as $site_id ) { + $push_service->delete_from_site( $post_id, $site_id ); + } + } + + /** + * Extract site IDs from sites array. + * + * @param array $sites Array of sites. + * @return array Array of site IDs. + */ + private function extract_site_ids( array $sites ): array { + $ids = array(); + + foreach ( $sites as $site ) { + if ( $site instanceof \WP_Post ) { + $ids[] = $site->ID; + } elseif ( is_object( $site ) && isset( $site->ID ) ) { + $ids[] = (int) $site->ID; + } elseif ( is_numeric( $site ) ) { + $ids[] = (int) $site; + } + } + + return array_unique( $ids ); + } + + /** + * Handle syn_pull_content cron action. + * + * @param array<\WP_Post|int> $sites Array of sites to pull from. + */ + public function on_pull_content( array $sites ): void { + if ( empty( $sites ) ) { + $sites = $this->get_selected_pull_sites(); + } + + $site_ids = $this->extract_site_ids( $sites ); + + if ( empty( $site_ids ) ) { + return; + } + + $settings = get_option( 'push_syndicate_settings' ); + $update_existing = ! empty( $settings['update_pulled_posts'] ) && 'on' === $settings['update_pulled_posts']; + + $pull_service = $this->container->get( PullService::class ); + \assert( $pull_service instanceof PullService ); + $pull_service->set_update_existing( $update_existing ); + + $pull_service->pull_from_sites( $site_ids ); + } + + /** + * Handle syn_refresh_pull_jobs cron action. + */ + public function on_refresh_pull_jobs(): void { + $sites = $this->get_selected_pull_sites(); + + $this->schedule_pull_jobs( $sites ); + } + + /** + * Get sites selected for pulling. + * + * @return array<\WP_Post> Array of site post objects. + */ + private function get_selected_pull_sites(): array { + $settings = get_option( 'push_syndicate_settings' ); + + if ( empty( $settings['selected_pull_sitegroups'] ) ) { + return array(); + } + + $selected_sitegroups = $settings['selected_pull_sitegroups']; + $sites = array(); + + foreach ( $selected_sitegroups as $sitegroup ) { + $term = get_term_by( 'slug', $sitegroup, 'syn_sitegroup' ); + if ( ! $term instanceof \WP_Term ) { + continue; + } + + $query = new \WP_Query( + array( + 'post_type' => PostTypeRegistrar::POST_TYPE, + 'posts_per_page' => 100, + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Required for sitegroup filtering. + array( + 'taxonomy' => PostTypeRegistrar::TAXONOMY, + 'field' => 'slug', + 'terms' => $sitegroup, + ), + ), + ) + ); + + $sites = array_merge( $sites, $query->posts ); + } + + usort( + $sites, + static function ( \WP_Post $a, \WP_Post $b ): int { + $a_time = (int) get_post_meta( $a->ID, 'syn_last_pull_time', true ); + $b_time = (int) get_post_meta( $b->ID, 'syn_last_pull_time', true ); + return $a_time <=> $b_time; + } + ); + + return $sites; + } + + /** + * Schedule pull jobs for sites. + * + * @param array<\WP_Post> $sites Array of site post objects. + */ + private function schedule_pull_jobs( array $sites ): void { + $old_sites = get_option( 'syn_old_pull_sites', array() ); + + if ( ! empty( $old_sites ) ) { + wp_clear_scheduled_hook( 'syn_pull_content', array( $old_sites ) ); + + foreach ( $old_sites as $old_site ) { + wp_clear_scheduled_hook( 'syn_pull_content', array( $old_site ) ); + wp_clear_scheduled_hook( 'syn_pull_content', array( array( $old_site ) ) ); + } + + wp_clear_scheduled_hook( 'syn_pull_content' ); + } + + foreach ( $sites as $site ) { + wp_schedule_event( + time() - 1, + 'syn_pull_time_interval', + 'syn_pull_content', + array( array( $site ) ) + ); + } + + update_option( 'syn_old_pull_sites', $sites ); + } + + /** + * Handle save_post action. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @param bool $update Whether this is an update. + */ + public function on_save_post( int $post_id, \WP_Post $post, bool $update ): void { + unset( $post_id, $update ); + + if ( PostTypeRegistrar::POST_TYPE === $post->post_type ) { + $this->schedule_deferred_pull_jobs_refresh(); + } + } + + /** + * Handle delete_post action. + * + * @param int $post_id Post ID being deleted. + */ + public function on_delete_post( int $post_id ): void { + $post = get_post( $post_id ); + if ( $post instanceof \WP_Post && PostTypeRegistrar::POST_TYPE === $post->post_type ) { + $this->schedule_deferred_pull_jobs_refresh(); + } + } + + /** + * Handle create_term action. + * + * @param int $term_id Term ID. + * @param int $tt_id Term taxonomy ID. + * @param string $taxonomy Taxonomy slug. + */ + public function on_create_term( int $term_id, int $tt_id, string $taxonomy ): void { + unset( $term_id, $tt_id ); + + if ( PostTypeRegistrar::TAXONOMY === $taxonomy ) { + $this->schedule_deferred_pull_jobs_refresh(); + } + } + + /** + * Handle delete_term action. + * + * @param int $term_id Term ID. + * @param int $tt_id Term taxonomy ID. + * @param string $taxonomy Taxonomy slug. + */ + public function on_delete_term( int $term_id, int $tt_id, string $taxonomy ): void { + unset( $term_id, $tt_id ); + + if ( PostTypeRegistrar::TAXONOMY === $taxonomy ) { + $this->schedule_deferred_pull_jobs_refresh(); + } + } + + /** + * Schedule a deferred refresh of pull jobs. + */ + private function schedule_deferred_pull_jobs_refresh(): void { + $debounce_key = 'syn_pull_jobs_refresh_pending'; + + if ( get_transient( $debounce_key ) ) { + return; + } + + set_transient( $debounce_key, '1', 5 ); + + if ( ! wp_next_scheduled( 'syn_refresh_pull_jobs' ) ) { + wp_schedule_single_event( time() + 5, 'syn_refresh_pull_jobs' ); + } + } +} diff --git a/tests/Behat/README.md b/tests/Behat/README.md new file mode 100644 index 0000000..f1fac64 --- /dev/null +++ b/tests/Behat/README.md @@ -0,0 +1,122 @@ +# Behat Test Strategy + +## Why Behat Tests Exist + +Behat tests verify the **CLI contract** - the actual user experience when running WP-CLI commands. +They are slow (~15s per scenario) because they run against a real WordPress instance via wp-env. + +Unit tests mock everything; Behat tests are full end-to-end. There's value in having a small number +of Behat tests that verify the complete workflow works, while keeping the majority of test coverage +in faster PHPUnit tests. + +## Testing Pyramid for CLI Commands + +``` + /\ + / \ Few Behat tests (smoke tests, CLI contract) + /----\ + / \ More PHPUnit integration tests (scenarios) + /--------\ + / \ Many unit tests (services, domain logic) + -------------- +``` + +- **Unit tests**: Test individual classes in isolation with mocked dependencies +- **Integration tests**: Test commands with real WordPress database, but invoke programmatically +- **Behat tests**: Test full CLI execution via wp-env shell + +## What Behat Tests Should Cover + +Behat tests provide unique value for: + +1. **CLI argument parsing** - WP-CLI's own parsing behaviour (missing required args, invalid values) +2. **Output format verification** - Exact text users see (table format, JSON output) +3. **Environment validation** - Plugin activation, command registration +4. **File I/O operations** - Import/export functionality with real files (if applicable) + +## Current Scenarios (and Rationale) + +| Feature | Scenario | Rationale | +|---------|----------|-----------| +| pull-site.feature | Missing --site_id parameter | Tests WP-CLI argument parsing | +| pull-site.feature | Invalid site_id | Tests error output format | +| pull-site.feature | Non-site post type | Tests validation error message | +| push-post.feature | Missing --post_id parameter | Tests WP-CLI argument parsing | +| push-post.feature | Invalid post_id | Tests error output format | +| push-post.feature | Post with no sitegroups | Tests error output format | +| sites-list.feature | List sites in table format | Tests output formatting | +| sites-list.feature | List sites in JSON format | Tests JSON output structure | +| sitegroups-list.feature | List sitegroups | Tests output formatting | + +## What NOT to Test in Behat + +These are better covered by PHPUnit integration tests: + +- **Individual edge cases** - e.g., site with missing configuration +- **Domain validation logic** - e.g., URL validation, transport type checking +- **Flag combinations** - e.g., `--verbose`, `--format` +- **Multiple sitegroups** - e.g., deduplication logic +- **Success paths** - Full push/pull success (requires remote setup) + +These scenarios can execute ~100x faster in PHPUnit than in Behat. + +## Adding New Behat Tests + +Before adding a Behat scenario, ask: + +1. **Does this test something only Behat can verify?** (CLI parsing, output format) +2. **Is this a critical happy path worth the ~15s execution cost?** +3. **Can this be tested faster with PHPUnit?** + +If the answer to #1 or #2 is "no", write a PHPUnit integration test instead. + +### Good Candidates for Behat + +- A new command that needs smoke testing +- Output format verification (table, JSON, CSV) +- WP-CLI argument parsing edge cases +- Multi-command workflows + +### Poor Candidates for Behat + +- Error handling for every invalid input +- Internal business logic +- Flag combinations +- Edge cases that don't affect CLI contract + +## Running Behat Tests + +```bash +# Run all Behat tests +composer behat + +# Run a specific feature +composer behat -- features/push-post.feature + +# Run a specific scenario by line number +composer behat -- features/push-post.feature:10 +``` + +## Running PHPUnit Integration Tests + +```bash +# Run CLI integration tests only +composer test:integration -- --group=cli + +# Run a specific test class +composer test:integration -- tests/Integration/CLI/PullSiteCommandTest.php +``` + +## Test Distribution Guidelines + +| Test Type | Purpose | Speed | Count per Command | +|-----------|---------|-------|-------------------| +| **Behat** | CLI contract verification | ~15s/scenario | 2-5 scenarios | +| **PHPUnit Integration** | Scenario coverage | ~0.5-2s/test | 10-20 tests | +| **PHPUnit Unit** | Service/domain logic | ~5-50ms/test | As needed | + +A well-tested CLI command should have: +- 2-5 Behat scenarios (smoke tests, output format) +- 10-20 PHPUnit integration tests (all scenarios) +- 0 unit tests for the command itself (it's a thin adapter) +- Comprehensive unit tests for the services the command calls diff --git a/tests/Integration/CLI/CliTestCase.php b/tests/Integration/CLI/CliTestCase.php new file mode 100644 index 0000000..46f0f79 --- /dev/null +++ b/tests/Integration/CLI/CliTestCase.php @@ -0,0 +1,312 @@ +output = new WpCliOutputCapture(); + } + + /** + * Create a PullSiteCommand with real dependencies. + * + * @return PullSiteCommand + */ + protected function create_pull_site_command(): PullSiteCommand { + $pull_service = $this->container()->get( PullServiceInterface::class ); + $site_repository = $this->container()->get( SiteRepositoryInterface::class ); + + \assert( $pull_service instanceof PullServiceInterface ); + \assert( $site_repository instanceof SiteRepositoryInterface ); + + return new PullSiteCommand( $pull_service, $site_repository ); + } + + /** + * Create a PushPostCommand with real dependencies. + * + * @return PushPostCommand + */ + protected function create_push_post_command(): PushPostCommand { + $push_service = $this->container()->get( PushServiceInterface::class ); + $site_repository = $this->container()->get( SiteRepositoryInterface::class ); + + \assert( $push_service instanceof PushServiceInterface ); + \assert( $site_repository instanceof SiteRepositoryInterface ); + + return new PushPostCommand( $push_service, $site_repository ); + } + + /** + * Invoke a CLI command directly. + * + * Captures all WP_CLI output during execution. + * + * @param object $command The command instance. + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + protected function invoke_command( object $command, array $args = array(), array $assoc_args = array() ): void { + $this->output->reset(); + $this->output->start_capture(); + + try { + $command->__invoke( $args, $assoc_args ); + } catch ( \Exception $e ) { + // Some commands throw exceptions instead of calling WP_CLI::error(). + $this->output->record_error( $e->getMessage() ); + } finally { + $this->output->stop_capture(); + } + } + + /** + * Get all stdout output as a string. + * + * @return string + */ + protected function get_stdout(): string { + return $this->output->get_stdout_string(); + } + + /** + * Get all stderr output as a string. + * + * @return string + */ + protected function get_stderr(): string { + return $this->output->get_stderr_string(); + } + + /** + * Get combined output (stdout + stderr). + * + * @return string + */ + protected function get_output(): string { + return $this->output->get_combined_string(); + } + + /** + * Assert that the command succeeded. + * + * @param string $message Optional assertion message. + */ + protected function assert_command_success( string $message = '' ): void { + $this->assertTrue( + $this->output->had_success(), + $message ?: 'Expected command to succeed. Output: ' . $this->get_output() + ); + $this->assertFalse( + $this->output->had_error(), + $message ?: 'Expected no error. Got: ' . $this->get_stderr() + ); + } + + /** + * Assert that the command failed. + * + * @param string $message Optional assertion message. + */ + protected function assert_command_error( string $message = '' ): void { + $this->assertTrue( + $this->output->had_error(), + $message ?: 'Expected command to fail. Output: ' . $this->get_output() + ); + } + + /** + * Assert that stdout contains a string. + * + * @param string $expected Expected substring. + * @param string $message Optional assertion message. + */ + protected function assert_stdout_contains( string $expected, string $message = '' ): void { + $this->assertStringContainsString( + $expected, + $this->get_stdout(), + $message ?: sprintf( 'Expected stdout to contain "%s". Got: %s', $expected, $this->get_stdout() ) + ); + } + + /** + * Assert that stderr contains a string. + * + * @param string $expected Expected substring. + * @param string $message Optional assertion message. + */ + protected function assert_stderr_contains( string $expected, string $message = '' ): void { + $this->assertStringContainsString( + $expected, + $this->get_stderr(), + $message ?: sprintf( 'Expected stderr to contain "%s". Got: %s', $expected, $this->get_stderr() ) + ); + } + + /** + * Assert that stdout does not contain a string. + * + * @param string $unexpected Unexpected substring. + * @param string $message Optional assertion message. + */ + protected function assert_stdout_not_contains( string $unexpected, string $message = '' ): void { + $this->assertStringNotContainsString( + $unexpected, + $this->get_stdout(), + $message ?: sprintf( 'Expected stdout to not contain "%s". Got: %s', $unexpected, $this->get_stdout() ) + ); + } + + /** + * Assert success message contains text. + * + * Combines asserting success status and message content. + * + * @param string $expected Expected substring in success message. + * @param string $message Optional assertion message. + */ + protected function assert_success_contains( string $expected, string $message = '' ): void { + $this->assert_command_success( $message ); + $this->assert_stdout_contains( $expected, $message ); + } + + /** + * Assert error message contains text. + * + * Combines asserting error status and message content. + * + * @param string $expected Expected substring in error message. + * @param string $message Optional assertion message. + */ + protected function assert_error_contains( string $expected, string $message = '' ): void { + $this->assert_command_error( $message ); + $this->assert_stderr_contains( $expected, $message ); + } + + /** + * Assert that a warning was output. + * + * @param string $expected Expected substring in warning message. + * @param string $message Optional assertion message. + */ + protected function assert_warning_contains( string $expected, string $message = '' ): void { + $this->assertTrue( + $this->output->had_warning(), + $message ?: 'Expected a warning. Output: ' . $this->get_output() + ); + $this->assert_stdout_contains( $expected, $message ); + } + + /** + * Create a syndication site for testing. + * + * @param array $args Site arguments. + * @return int The site post ID. + */ + protected function create_syndication_site( array $args = array() ): int { + $defaults = array( + 'post_type' => 'syn_site', + 'post_title' => 'Test Site', + 'post_status' => 'publish', + ); + + $site_id = wp_insert_post( array_merge( $defaults, $args ) ); + + // Set default site meta if not provided. + if ( ! isset( $args['meta_input'] ) ) { + update_post_meta( $site_id, 'syn_site_url', 'https://example.com' ); + update_post_meta( $site_id, 'syn_site_id', '1' ); + update_post_meta( $site_id, 'syn_transport_type', 'rest' ); + update_post_meta( $site_id, 'syn_site_enabled', '1' ); + } + + return $site_id; + } + + /** + * Create a sitegroup for testing. + * + * @param string $name Sitegroup name. + * @return int The term ID. + */ + protected function create_sitegroup( string $name = 'Test Sitegroup' ): int { + $term = wp_insert_term( $name, 'syn_sitegroup' ); + + if ( is_wp_error( $term ) ) { + return 0; + } + + return $term['term_id']; + } + + /** + * Assign a site to a sitegroup. + * + * @param int $site_id The site post ID. + * @param int $sitegroup_id The sitegroup term ID. + */ + protected function assign_site_to_sitegroup( int $site_id, int $sitegroup_id ): void { + wp_set_object_terms( $site_id, $sitegroup_id, 'syn_sitegroup' ); + } + + /** + * Create a post with syndication sitegroups assigned. + * + * @param array $sitegroup_ids Sitegroup IDs to assign. + * @return int The post ID. + */ + protected function create_post_with_sitegroups( array $sitegroup_ids ): int { + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Test Post', + 'post_status' => 'publish', + ) + ); + + update_post_meta( $post_id, '_syn_selected_sitegroups', $sitegroup_ids ); + + return $post_id; + } +} diff --git a/tests/Integration/CLI/PullSiteCommandTest.php b/tests/Integration/CLI/PullSiteCommandTest.php new file mode 100644 index 0000000..1f1cc1e --- /dev/null +++ b/tests/Integration/CLI/PullSiteCommandTest.php @@ -0,0 +1,209 @@ +create_pull_site_command(); + + $this->invoke_command( $command, array(), array() ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'valid site' ); + } + + /** + * Test that command errors when site_id is zero. + */ + public function test_errors_when_site_id_is_zero(): void { + $command = $this->create_pull_site_command(); + + $this->invoke_command( $command, array(), array( 'site_id' => 0 ) ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'valid site' ); + } + + /** + * Test that command errors when site does not exist. + */ + public function test_errors_when_site_does_not_exist(): void { + $command = $this->create_pull_site_command(); + + $this->invoke_command( $command, array(), array( 'site_id' => 99999 ) ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'valid site' ); + } + + /** + * Test that command errors when post is not a syn_site. + */ + public function test_errors_when_post_is_not_syn_site(): void { + // Create a regular post, not a syn_site. + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Regular Post', + 'post_status' => 'publish', + ) + ); + + $command = $this->create_pull_site_command(); + + $this->invoke_command( $command, array(), array( 'site_id' => $post_id ) ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'valid site' ); + + // Clean up. + wp_delete_post( $post_id, true ); + } + + /** + * Test that command logs site name when pulling. + */ + public function test_logs_site_name_when_pulling(): void { + $site_id = $this->create_syndication_site( + array( + 'post_title' => 'My Test Site', + ) + ); + + $command = $this->create_pull_site_command(); + + $this->invoke_command( $command, array(), array( 'site_id' => $site_id ) ); + + // The command should log the site name (even if pull fails due to no real remote). + $this->assert_stdout_contains( 'My Test Site' ); + + // Clean up. + wp_delete_post( $site_id, true ); + } + + /** + * Test that command handles pull from site with missing configuration. + * + * A site without proper transport configuration should still be handled + * gracefully by the command. + */ + public function test_handles_site_with_missing_transport_config(): void { + $site_id = wp_insert_post( + array( + 'post_type' => 'syn_site', + 'post_title' => 'Misconfigured Site', + 'post_status' => 'publish', + ) + ); + + // Don't set any meta - site has no configuration. + + $command = $this->create_pull_site_command(); + + $this->invoke_command( $command, array(), array( 'site_id' => $site_id ) ); + + // Command should handle this gracefully (may error or warn). + $output = $this->get_output(); + $this->assertNotEmpty( $output, 'Expected some output from command' ); + + // Clean up. + wp_delete_post( $site_id, true ); + } + + /** + * Test that command accepts verbose flag. + */ + public function test_accepts_verbose_flag(): void { + $site_id = $this->create_syndication_site(); + + $command = $this->create_pull_site_command(); + + // Should not error on verbose flag. + $this->invoke_command( + $command, + array(), + array( + 'site_id' => $site_id, + 'verbose' => true, + ) + ); + + // The command should have run (may succeed or fail depending on remote). + $output = $this->get_output(); + $this->assertNotEmpty( $output, 'Expected some output from command' ); + + // Clean up. + wp_delete_post( $site_id, true ); + } + + /** + * Test that command handles disabled site. + * + * A disabled site should still be pullable via CLI (the enabled flag + * is for automated cron pulls, not manual CLI operations). + */ + public function test_handles_disabled_site(): void { + $site_id = $this->create_syndication_site( + array( + 'post_title' => 'Disabled Site', + ) + ); + + // Disable the site. + update_post_meta( $site_id, 'syn_site_enabled', '0' ); + + $command = $this->create_pull_site_command(); + + $this->invoke_command( $command, array(), array( 'site_id' => $site_id ) ); + + // Command should still attempt the pull. + $this->assert_stdout_contains( 'Disabled Site' ); + + // Clean up. + wp_delete_post( $site_id, true ); + } + + /** + * Test that command handles draft site. + * + * A site in draft status should still be pullable via CLI. + */ + public function test_handles_draft_site(): void { + $site_id = $this->create_syndication_site( + array( + 'post_title' => 'Draft Site', + 'post_status' => 'draft', + ) + ); + + $command = $this->create_pull_site_command(); + + $this->invoke_command( $command, array(), array( 'site_id' => $site_id ) ); + + // Command should attempt the pull. + $this->assert_stdout_contains( 'Draft Site' ); + + // Clean up. + wp_delete_post( $site_id, true ); + } +} diff --git a/tests/Integration/CLI/PushPostCommandTest.php b/tests/Integration/CLI/PushPostCommandTest.php new file mode 100644 index 0000000..a9b08bb --- /dev/null +++ b/tests/Integration/CLI/PushPostCommandTest.php @@ -0,0 +1,345 @@ +create_push_post_command(); + + $this->invoke_command( $command, array(), array() ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'Invalid post_id' ); + } + + /** + * Test that command errors when post_id is zero. + */ + public function test_errors_when_post_id_is_zero(): void { + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => 0 ) ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'Invalid post_id' ); + } + + /** + * Test that command errors when post does not exist. + */ + public function test_errors_when_post_does_not_exist(): void { + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => 99999 ) ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'Invalid post_id' ); + } + + /** + * Test that command errors when post has no sitegroups. + */ + public function test_errors_when_post_has_no_sitegroups(): void { + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Test Post', + 'post_status' => 'publish', + ) + ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'no selected sitegroups' ); + + // Clean up. + wp_delete_post( $post_id, true ); + } + + /** + * Test that command errors when post has empty sitegroups array. + */ + public function test_errors_when_post_has_empty_sitegroups(): void { + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Test Post', + 'post_status' => 'publish', + ) + ); + + // Set empty sitegroups array. + update_post_meta( $post_id, '_syn_selected_sitegroups', array() ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + $this->assert_command_error(); + $this->assert_stderr_contains( 'no selected sitegroups' ); + + // Clean up. + wp_delete_post( $post_id, true ); + } + + /** + * Test that command logs progress when pushing. + */ + public function test_logs_progress_when_pushing(): void { + // Create sitegroup with a site. + $sitegroup_id = $this->create_sitegroup( 'Test Group' ); + $site_id = $this->create_syndication_site( array( 'post_title' => 'Test Site' ) ); + $this->assign_site_to_sitegroup( $site_id, $sitegroup_id ); + + // Create post assigned to sitegroup. + $post_id = $this->create_post_with_sitegroups( array( $sitegroup_id ) ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + // Should log the push progress. + $this->assert_stdout_contains( 'Pushing post' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_post( $site_id, true ); + wp_delete_term( $sitegroup_id, 'syn_sitegroup' ); + } + + /** + * Test that command filters out disabled sites. + */ + public function test_filters_out_disabled_sites(): void { + // Create sitegroup with one enabled and one disabled site. + $sitegroup_id = $this->create_sitegroup( 'Mixed Group' ); + $enabled_site = $this->create_syndication_site( array( 'post_title' => 'Enabled Site' ) ); + $disabled_site = $this->create_syndication_site( array( 'post_title' => 'Disabled Site' ) ); + + // Disable one site. + update_post_meta( $disabled_site, 'syn_site_enabled', '0' ); + + $this->assign_site_to_sitegroup( $enabled_site, $sitegroup_id ); + $this->assign_site_to_sitegroup( $disabled_site, $sitegroup_id ); + + // Create post assigned to sitegroup. + $post_id = $this->create_post_with_sitegroups( array( $sitegroup_id ) ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + // Should only push to 1 site (the enabled one). + $this->assert_stdout_contains( '1 site(s)' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_post( $enabled_site, true ); + wp_delete_post( $disabled_site, true ); + wp_delete_term( $sitegroup_id, 'syn_sitegroup' ); + } + + /** + * Test that command handles sitegroup with no enabled sites. + */ + public function test_errors_when_sitegroup_has_no_enabled_sites(): void { + // Create sitegroup with only a disabled site. + $sitegroup_id = $this->create_sitegroup( 'Disabled Group' ); + $disabled_site = $this->create_syndication_site( array( 'post_title' => 'Disabled Site' ) ); + + // Disable the site. + update_post_meta( $disabled_site, 'syn_site_enabled', '0' ); + + $this->assign_site_to_sitegroup( $disabled_site, $sitegroup_id ); + + // Create post assigned to sitegroup. + $post_id = $this->create_post_with_sitegroups( array( $sitegroup_id ) ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + // Should error because no enabled sites exist. + $this->assert_command_error(); + $this->assert_stderr_contains( 'no selected sitegroups' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_post( $disabled_site, true ); + wp_delete_term( $sitegroup_id, 'syn_sitegroup' ); + } + + /** + * Test that command handles sitegroup with no sites. + */ + public function test_errors_when_sitegroup_is_empty(): void { + // Create empty sitegroup. + $sitegroup_id = $this->create_sitegroup( 'Empty Group' ); + + // Create post assigned to empty sitegroup. + $post_id = $this->create_post_with_sitegroups( array( $sitegroup_id ) ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + // Should error because sitegroup has no sites. + $this->assert_command_error(); + $this->assert_stderr_contains( 'no selected sitegroups' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_term( $sitegroup_id, 'syn_sitegroup' ); + } + + /** + * Test that command accepts verbose flag. + */ + public function test_accepts_verbose_flag(): void { + // Create sitegroup with a site. + $sitegroup_id = $this->create_sitegroup( 'Verbose Test Group' ); + $site_id = $this->create_syndication_site( array( 'post_title' => 'Test Site' ) ); + $this->assign_site_to_sitegroup( $site_id, $sitegroup_id ); + + // Create post assigned to sitegroup. + $post_id = $this->create_post_with_sitegroups( array( $sitegroup_id ) ); + + $command = $this->create_push_post_command(); + + // Should not error on verbose flag. + $this->invoke_command( + $command, + array(), + array( + 'post_id' => $post_id, + 'verbose' => true, + ) + ); + + // The command should have run. + $output = $this->get_output(); + $this->assertNotEmpty( $output, 'Expected some output from command' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_post( $site_id, true ); + wp_delete_term( $sitegroup_id, 'syn_sitegroup' ); + } + + /** + * Test that command handles multiple sitegroups. + */ + public function test_handles_multiple_sitegroups(): void { + // Create two sitegroups with one site each. + $sitegroup1 = $this->create_sitegroup( 'Group 1' ); + $sitegroup2 = $this->create_sitegroup( 'Group 2' ); + + $site1 = $this->create_syndication_site( array( 'post_title' => 'Site 1' ) ); + $site2 = $this->create_syndication_site( array( 'post_title' => 'Site 2' ) ); + + $this->assign_site_to_sitegroup( $site1, $sitegroup1 ); + $this->assign_site_to_sitegroup( $site2, $sitegroup2 ); + + // Create post assigned to both sitegroups. + $post_id = $this->create_post_with_sitegroups( array( $sitegroup1, $sitegroup2 ) ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + // Should push to 2 sites. + $this->assert_stdout_contains( '2 site(s)' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_post( $site1, true ); + wp_delete_post( $site2, true ); + wp_delete_term( $sitegroup1, 'syn_sitegroup' ); + wp_delete_term( $sitegroup2, 'syn_sitegroup' ); + } + + /** + * Test that command deduplicates sites appearing in multiple sitegroups. + */ + public function test_deduplicates_sites_in_multiple_sitegroups(): void { + // Create two sitegroups with the same site. + $sitegroup1 = $this->create_sitegroup( 'Group 1' ); + $sitegroup2 = $this->create_sitegroup( 'Group 2' ); + + $shared_site = $this->create_syndication_site( array( 'post_title' => 'Shared Site' ) ); + + $this->assign_site_to_sitegroup( $shared_site, $sitegroup1 ); + $this->assign_site_to_sitegroup( $shared_site, $sitegroup2 ); + + // Create post assigned to both sitegroups. + $post_id = $this->create_post_with_sitegroups( array( $sitegroup1, $sitegroup2 ) ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + // Should push to only 1 site (deduplicated). + $this->assert_stdout_contains( '1 site(s)' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_post( $shared_site, true ); + wp_delete_term( $sitegroup1, 'syn_sitegroup' ); + wp_delete_term( $sitegroup2, 'syn_sitegroup' ); + } + + /** + * Test that command handles draft posts. + */ + public function test_handles_draft_posts(): void { + // Create sitegroup with a site. + $sitegroup_id = $this->create_sitegroup( 'Test Group' ); + $site_id = $this->create_syndication_site( array( 'post_title' => 'Test Site' ) ); + $this->assign_site_to_sitegroup( $site_id, $sitegroup_id ); + + // Create draft post. + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Draft Post', + 'post_status' => 'draft', + ) + ); + update_post_meta( $post_id, '_syn_selected_sitegroups', array( $sitegroup_id ) ); + + $command = $this->create_push_post_command(); + + $this->invoke_command( $command, array(), array( 'post_id' => $post_id ) ); + + // Should attempt to push (CLI allows pushing drafts). + $this->assert_stdout_contains( 'Pushing post' ); + + // Clean up. + wp_delete_post( $post_id, true ); + wp_delete_post( $site_id, true ); + wp_delete_term( $sitegroup_id, 'syn_sitegroup' ); + } +} diff --git a/tests/Integration/CLI/WpCliOutputCapture.php b/tests/Integration/CLI/WpCliOutputCapture.php new file mode 100644 index 0000000..beff417 --- /dev/null +++ b/tests/Integration/CLI/WpCliOutputCapture.php @@ -0,0 +1,156 @@ +reset(); + } + + /** + * Stop capturing (no-op, but here for API consistency). + */ + public function stop_capture(): void { + // Nothing to do - the stub stores calls statically. + } + + /** + * Record an error manually (for exceptions). + * + * @param string $message The error message. + */ + public function record_error( string $message ): void { + if ( class_exists( '\WP_CLI', false ) ) { + \WP_CLI::error( $message, false ); + } + } + + /** + * Get stdout as a single string. + * + * Includes: line, log, success, warning messages. + * + * @return string + */ + public function get_stdout_string(): string { + $lines = array(); + + if ( ! class_exists( '\WP_CLI', false ) || ! property_exists( '\WP_CLI', 'calls' ) ) { + return ''; + } + + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Static property in stub. + foreach ( \WP_CLI::$calls as $call ) { + $method = $call[0]; + if ( in_array( $method, array( 'line', 'log' ), true ) ) { + $lines[] = $call[1] ?? ''; + } elseif ( 'success' === $method ) { + $lines[] = 'Success: ' . ( $call[1] ?? '' ); + } elseif ( 'warning' === $method ) { + $lines[] = 'Warning: ' . ( $call[1] ?? '' ); + } + } + + return implode( "\n", $lines ); + } + + /** + * Get stderr as a single string. + * + * Includes: error messages. + * + * @return string + */ + public function get_stderr_string(): string { + $lines = array(); + + if ( ! class_exists( '\WP_CLI', false ) || ! property_exists( '\WP_CLI', 'calls' ) ) { + return ''; + } + + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Static property in stub. + foreach ( \WP_CLI::$calls as $call ) { + if ( 'error' === $call[0] ) { + $lines[] = 'Error: ' . ( $call[1] ?? '' ); + } + } + + return implode( "\n", $lines ); + } + + /** + * Get combined output as a single string. + * + * @return string + */ + public function get_combined_string(): string { + $stdout = $this->get_stdout_string(); + $stderr = $this->get_stderr_string(); + + if ( '' === $stdout && '' === $stderr ) { + return ''; + } + + return trim( $stdout . "\n" . $stderr ); + } + + /** + * Check if an error occurred. + * + * @return bool + */ + public function had_error(): bool { + return class_exists( '\WP_CLI', false ) + && method_exists( '\WP_CLI', 'was_called' ) + && \WP_CLI::was_called( 'error' ); + } + + /** + * Check if success occurred. + * + * @return bool + */ + public function had_success(): bool { + return class_exists( '\WP_CLI', false ) + && method_exists( '\WP_CLI', 'was_called' ) + && \WP_CLI::was_called( 'success' ); + } + + /** + * Check if a warning occurred. + * + * @return bool + */ + public function had_warning(): bool { + return class_exists( '\WP_CLI', false ) + && method_exists( '\WP_CLI', 'was_called' ) + && \WP_CLI::was_called( 'warning' ); + } +} diff --git a/tests/Integration/TestCase.php b/tests/Integration/TestCase.php new file mode 100644 index 0000000..0c7f25d --- /dev/null +++ b/tests/Integration/TestCase.php @@ -0,0 +1,30 @@ + + */ + public static array $calls = array(); + + /** + * Reset the call tracker. + */ + public static function reset(): void { + self::$calls = array(); + } + + /** + * Record a success message. + * + * @param string $message The message. + */ + public static function success( string $message ): void { + self::$calls[] = array( 'success', $message ); + } + + /** + * Record an error message. + * + * Unlike real WP_CLI, this does NOT exit. Tests should check + * was_called('error') to verify error state. + * + * @param string $message The message. + * @param bool $exit Whether to exit (ignored in stub). + */ + public static function error( string $message, bool $exit = true ): void { + self::$calls[] = array( 'error', $message ); + } + + /** + * Record a warning message. + * + * @param string $message The message. + */ + public static function warning( string $message ): void { + self::$calls[] = array( 'warning', $message ); + } + + /** + * Record a line of output. + * + * @param string $message The message. + */ + public static function line( string $message = '' ): void { + self::$calls[] = array( 'line', $message ); + } + + /** + * Record a log message. + * + * @param string $message The message. + */ + public static function log( string $message ): void { + self::$calls[] = array( 'log', $message ); + } + + /** + * Record a debug message. + * + * @param string $message The message. + * @param string $group Debug group. + */ + public static function debug( string $message, string $group = '' ): void { + self::$calls[] = array( 'debug', $message, $group ); + } + + /** + * Confirm prompt (always proceeds in stub). + * + * @param string $question The question. + * @param array $assoc_args Associative arguments. + */ + public static function confirm( string $question, array $assoc_args = array() ): void { + self::$calls[] = array( 'confirm', $question ); + // In tests, this is effectively a no-op - always proceeds. + } + + /** + * Get a call by method name. + * + * @param string $method The method name. + * @return array|null The call data, or null if not found. + */ + public static function get_call( string $method ): ?array { + foreach ( self::$calls as $call ) { + if ( $call[0] === $method ) { + return $call; + } + } + return null; + } + + /** + * Get all calls of a specific method. + * + * @param string $method The method name. + * @return array> Array of calls. + */ + public static function get_calls( string $method ): array { + $result = array(); + foreach ( self::$calls as $call ) { + if ( $call[0] === $method ) { + $result[] = $call; + } + } + return $result; + } + + /** + * Check if a method was called. + * + * @param string $method The method name. + * @return bool True if called. + */ + public static function was_called( string $method ): bool { + return null !== self::get_call( $method ); + } + + /** + * Add a command (no-op for testing). + * + * @param string $name Command name. + * @param mixed $command Command class or callable. + */ + public static function add_command( string $name, $command ): void { + // No-op - we don't need to actually register commands in tests. + } + } +} + +if ( ! class_exists( 'WP_CLI_Command' ) ) { + /** + * Minimal WP_CLI_Command stub. + * + * WP-CLI commands extend this base class. + */ + class WP_CLI_Command { + // Empty base class - WP_CLI commands extend this. + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 93f18ed..a869029 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -57,6 +57,19 @@ } if ( $is_integration ) { + /* + * Load WP_CLI stub BEFORE WordPress bootstraps. + * + * This ensures CLI commands don't exit on WP_CLI::error() calls, + * and output can be captured for assertions in tests. + */ + require_once __DIR__ . '/Stubs/WpCliStub.php'; + + // Define WP_CLI constant so plugin registers CLI commands. + if ( ! defined( 'WP_CLI' ) ) { + define( 'WP_CLI', true ); + } + $_tests_dir = WPIntegration\get_path_to_wp_test_dir(); // Give access to tests_add_filter() function. @@ -80,4 +93,6 @@ function (): void { * Load test dependencies. */ require_once __DIR__ . '/Integration/EncryptorTestCase.php'; + require_once __DIR__ . '/Integration/CLI/WpCliOutputCapture.php'; + require_once __DIR__ . '/Integration/CLI/CliTestCase.php'; } From e8a606aa1df9108e285ab362e1d9cfb6a5086e2e Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 29 Jan 2026 12:44:17 +0000 Subject: [PATCH 16/16] fix: remove mixed type hint for PHP 7.4 compatibility The WP_Error stub used the `mixed` type hint which is only available in PHP 8.0+, causing a fatal error on PHP 7.4 CI runs. Co-Authored-By: Claude Opus 4.5 --- tests/Unit/Stubs/WP_Error.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Stubs/WP_Error.php b/tests/Unit/Stubs/WP_Error.php index 57c5b54..63014f4 100644 --- a/tests/Unit/Stubs/WP_Error.php +++ b/tests/Unit/Stubs/WP_Error.php @@ -40,7 +40,7 @@ class WP_Error { * @param string $message Error message. * @param mixed $data Error data. */ - public function __construct( string $code = '', string $message = '', mixed $data = '' ) { + public function __construct( string $code = '', string $message = '', $data = '' ) { if ( ! empty( $code ) ) { $this->errors[ $code ][] = $message; if ( ! empty( $data ) ) {