diff --git a/includes/Application/Bootstrapper.php b/includes/Application/Bootstrapper.php new file mode 100644 index 0000000..e40c16d --- /dev/null +++ b/includes/Application/Bootstrapper.php @@ -0,0 +1,201 @@ +container = $container; + $this->hooks = $container->get( HookManager::class ); + $this->hook_registrar = new HookRegistrar( $container, $this->hooks ); + } + + /** + * 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 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. + $this->hooks->add_action( + 'syn_get_container', + function (): Container { + return $this->container; + } + ); + + // 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; + } + + /** + * 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/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/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/HookRegistrar.php b/includes/Application/HookRegistrar.php new file mode 100644 index 0000000..26b0189 --- /dev/null +++ b/includes/Application/HookRegistrar.php @@ -0,0 +1,720 @@ +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 ); + + // 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 ); + + // 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 ); + } + + /** + * 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. + * + * 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 { + $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' ); + } + + /** + * Handle admin_init action. + * + * @todo Implement admin initialisation. + */ + public function on_admin_init(): void { + // Will handle admin setup. + } + + /** + * 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. + */ + public function on_transition_post_status( string $new_status, string $old_status, \WP_Post $post ): void { + 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. + * + * Deletes syndicated content from remote sites when a post is trashed. + * Mirrors legacy delete_content(). + * + * @param int $post_id Post ID being trashed. + */ + public function on_trash_post( int $post_id ): void { + // 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; + } + + /** + * 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. + * + * 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 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. + * + * 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. + */ + public function on_save_post( int $post_id, \WP_Post $post, bool $update ): void { + 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. + * + * Triggers pull job refresh when a syn_site is deleted. + * Mirrors legacy handle_site_change(). + * + * @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. + * + * 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. + */ + 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. + * + * 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. + */ + 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. + * + * 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/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/Application/Services/PullService.php b/includes/Application/Services/PullService.php new file mode 100644 index 0000000..623856b --- /dev/null +++ b/includes/Application/Services/PullService.php @@ -0,0 +1,480 @@ +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 ); + $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.' ); + } + + // 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 ); + $log->end_pull(); + 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 ); + + // Log the result. + $this->log_pulled_post_result( $log, $result, $post_data ); + + 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 ); + $log->end_pull(); + + if ( ! empty( $errors ) ) { + return PullResult::partial( $site_id, $created, $updated, $skipped, $errors ); + } + + 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. + * + * @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, $site_id ); + $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 ); + 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 ); + + 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, + ); + } + + // 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, $site_id ); + $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, + ); + } + + // 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 ); + + 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. + * @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, 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_status, + '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 ); + } + + /** + * 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 new file mode 100644 index 0000000..c924d81 --- /dev/null +++ b/includes/Application/Services/PushService.php @@ -0,0 +1,371 @@ +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(); + } + + $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 ) { + $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. + * + * @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/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/DI/Container.php b/includes/Infrastructure/DI/Container.php index 6463fee..e860cac 100644 --- a/includes/Infrastructure/DI/Container.php +++ b/includes/Infrastructure/DI/Container.php @@ -11,10 +11,12 @@ 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; use Automattic\Syndication\Infrastructure\WordPress\HookManager; +use Automattic\Syndication\Infrastructure\WordPress\PostTypeRegistrar; /** * Simple dependency injection container for Syndication services. @@ -153,14 +155,21 @@ 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 ); + 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 ); + \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/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/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/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/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/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/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-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-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-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/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"; - } -} diff --git a/includes/class-wp-push-syndication-server.php b/includes/class-wp-push-syndication-server.php index d7adad1..78f3bbc 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' ) ); @@ -50,24 +60,17 @@ 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 ); 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' ) ); $this->register_syndicate_actions(); @@ -100,23 +103,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 +138,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 +161,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 ); @@ -172,34 +181,34 @@ 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 ) { $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 +218,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 +231,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 +435,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 +480,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 +884,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 +951,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 +1035,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 +1108,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 +1163,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 ) { @@ -942,106 +1530,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 ) { @@ -1330,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 ) { @@ -1397,231 +1870,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; diff --git a/push-syndication.php b/push-syndication.php index a4659da..ec749ba 100644 --- a/push-syndication.php +++ b/push-syndication.php @@ -16,55 +16,70 @@ * Text Domain: push-syndication */ +declare( strict_types=1 ); + +// 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' ); +} + // Load PSR-4 autoloader for namespaced classes. require_once __DIR__ . '/includes/Autoloader.php'; Syndication_Autoloader::register( __DIR__ ); -if ( ! defined( 'PUSH_SYNDICATE_KEY' ) ) { - define( 'PUSH_SYNDICATE_KEY', 'PUSH_SYNDICATE_KEY' ); +// 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 instance. + * + * 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 \Automattic\Syndication\Infrastructure\DI\Container::instance(); } /** - * 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'; -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. -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(); +// Initialize site health monitoring (replaces legacy event counter, failure monitor, and auto-retry). +( new \Automattic\Syndication\Infrastructure\Health\SiteHealthMonitor() )->register(); -// Create the site auto retry functionality. -require __DIR__ . '/includes/class-syndication-site-auto-retry.php'; -new Failed_Syndication_Auto_Retry(); +// Initialize syndication notifications. +( new \Automattic\Syndication\Infrastructure\Notification\SyndicationNotifier() )->register_hooks(); -// 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'; +// Initialize log viewers (admin only). +if ( is_admin() ) { + ( new \Automattic\Syndication\Infrastructure\Logging\PullLogViewer() )->register(); + ( new \Automattic\Syndication\Infrastructure\Logging\PushLogViewer() )->register(); -// 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() ); + // Legacy log viewer (only shows if legacy logs exist). + require_once __DIR__ . '/includes/class-syndication-logger-viewer.php'; + new Syndication_Logger_Viewer(); } -// @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/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/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..f0fba9e --- /dev/null +++ b/tests/Integration/Application/HookRegistrationTest.php @@ -0,0 +1,167 @@ +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 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. + */ + 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/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/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(); + } +} 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/HookRegistrarTest.php b/tests/Unit/Application/HookRegistrarTest.php new file mode 100644 index 0000000..c5b6321 --- /dev/null +++ b/tests/Unit/Application/HookRegistrarTest.php @@ -0,0 +1,547 @@ +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 ); + } + ); + + // 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 ); + } + + /** + * 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( '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(); + 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 skips on autosave. + */ + 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 exit early and not call any functions. + $this->registrar->on_transition_post_status( 'publish', 'draft', $post ); + + $this->assertTrue( true ); + } + + /** + * Test on_trash_post skips when delete disabled. + */ + 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_trash_post skips when no slave posts. + */ + 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_save_post triggers refresh for syn_site. + */ + 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_delete_post triggers refresh for syn_site. + */ + 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 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_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 ); + } + + /** + * Test on_init registers post type and taxonomy. + */ + 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(); + } + + /** + * 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(); + } + + /** + * Test on_admin_init is callable. + */ + public function test_on_admin_init_is_callable(): void { + $this->registrar->on_admin_init(); + + $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 ); + } + + /** + * 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 ); + } +} diff --git a/tests/Unit/Application/Services/PullServiceTest.php b/tests/Unit/Application/Services/PullServiceTest.php new file mode 100644 index 0000000..13f31f0 --- /dev/null +++ b/tests/Unit/Application/Services/PullServiceTest.php @@ -0,0 +1,424 @@ +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 ); + } + + /** + * 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'; + $post->post_title = 'Test 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'; + $post->post_title = 'Test 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'; + $post->post_title = 'Test 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'; + $post->post_title = 'Test 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'; + $post->post_title = 'Test 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'; + $post->post_title = 'Test 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'; + $post->post_title = 'Test 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 ); + } +} 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/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 ); + } +} 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 ); + } +} 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 ) ) { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ed49b7d..a869029 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -50,14 +50,26 @@ 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; } 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. @@ -81,5 +93,6 @@ function (): void { * Load test dependencies. */ require_once __DIR__ . '/Integration/EncryptorTestCase.php'; - require_once __DIR__ . '/Integration/Syndication_Mock_Client.php'; + require_once __DIR__ . '/Integration/CLI/WpCliOutputCapture.php'; + require_once __DIR__ . '/Integration/CLI/CliTestCase.php'; }