diff --git a/src/CLI/Command.php b/src/CLI/Command.php index 4fef4eb..ec6a881 100644 --- a/src/CLI/Command.php +++ b/src/CLI/Command.php @@ -32,13 +32,25 @@ class Command extends WP_CLI_Command { */ private IntegrationRegistry $integration_registry; + /** + * Memory manager instance. + * + * @var MemoryManager + */ + private MemoryManager $memory_manager; + /** * Dependency injector. * * @param IntegrationRegistry $integration_registry The integration registry. + * @param MemoryManager $memory_manager The memory manager. */ - public function init( IntegrationRegistry $integration_registry ) { + public function init( + IntegrationRegistry $integration_registry, + MemoryManager $memory_manager + ) { $this->integration_registry = $integration_registry; + $this->memory_manager = $memory_manager; } /** @@ -111,7 +123,7 @@ public function generate( $args, $assoc_args ) { // Initialize the feed and walker, set them up. $feed = $integration->create_feed(); - $walker = new ProductWalker( $integration->get_product_mapper(), $integration->get_feed_validator(), $feed ); + $walker = ProductWalker::from_integration( $integration, $feed ); $walker->set_batch_size( $batch_size ); $walker->add_time_limit( $timeout ); @@ -136,7 +148,7 @@ function ( WalkerProgress $progress ) use ( $silent, &$iteration_time, &$total_i $per_item = round( ( $duration / $items_count ) * 1000, 2 ); - WP_CLI::log( "Batch $progress->processed_batches/$progress->total_batch_count: Processed $progress->processed_items/$progress->total_count products. Available memory: " . MemoryManager::get_available_memory() . "%. Time per item: $per_item ms" ); + WP_CLI::log( "Batch $progress->processed_batches/$progress->total_batch_count: Processed $progress->processed_items/$progress->total_count products. Available memory: " . $this->memory_manager->get_available_memory() . "%. Time per item: $per_item ms" ); } ); diff --git a/src/Core/Plugin.php b/src/Core/Plugin.php index 38fca2f..8c3d0ed 100644 --- a/src/Core/Plugin.php +++ b/src/Core/Plugin.php @@ -28,7 +28,7 @@ final class Plugin { * * @var Container */ - private Container $container; + private $container; /** * Integration registry. diff --git a/src/Feed/ProductLoader.php b/src/Feed/ProductLoader.php new file mode 100644 index 0000000..23a3df1 --- /dev/null +++ b/src/Feed/ProductLoader.php @@ -0,0 +1,32 @@ +mapper = $mapper; - $this->validator = $validator; - $this->feed = $feed; + $this->mapper = $mapper; + $this->validator = $validator; + $this->feed = $feed; + $this->product_loader = $product_loader; + $this->memory_manager = $memory_manager; + $this->query_args = $query_args; + } + + /** + * Creates a new instance of the ProductWalker class based on an integration. + * + * The walker will mostly be set up based on the integration. + * The feed is provided externally, as it might be based on the context (CLI, REST, Action Scheduler, etc.). + * + * @param IntegrationInterface $integration The integration. + * @param FeedInterface $feed The feed. + * @return self The ProductWalker instance. + */ + public static function from_integration( + IntegrationInterface $integration, + FeedInterface $feed + ): self { + $query_args = array_merge( + [ + 'status' => [ 'publish' ], + 'return' => 'objects', + ], + $integration->get_product_feed_query_args() + ); + + /** + * Allows the base arguments for querying products for product feeds to be changed. + * + * Variable products are not included by default, as their variations will be included. + * + * @since 0.1.0 + * + * @param array $query_args The arguments to pass to wc_get_products(). + * @param IntegrationInterface $integration The integration that the query belongs to. + * @return array + */ + $query_args = apply_filters( + 'wpfoai_product_feed_args', + $query_args, + $integration + ); + + $instance = new self( + $integration->get_product_mapper(), + $integration->get_feed_validator(), + $feed, + wpfoai_get_service( ProductLoader::class ), + wpfoai_get_service( MemoryManager::class ), + $query_args + ); + + return $instance; } /** @@ -99,42 +181,19 @@ public function add_time_limit( int $time_limit ): self { * Walks through all products. * * @param callable $callback The callback to call after each batch of products is processed. - * @param array $additional_args Optional. Additional arguments to merge into the base query args. * @return int The total number of products processed. */ - public function walk( ?callable $callback = null, array $additional_args = [] ): int { + public function walk( ?callable $callback = null ): int { $progress = null; - /** - * Allows the base arguments for querying products for product feeds to be changed. - * - * Variable products are not included by default, as their variations will be included. - * - * @since 0.1.0 - * - * @param array $args The arguments to pass to wc_get_products(). - * @return array - */ - $args = apply_filters( - 'wpfoai_product_feed_args', - array_merge( - [ - 'status' => [ 'publish' ], - 'type' => [ 'simple', 'variation' ], - 'return' => 'objects', - ], - $additional_args - ) - ); - // Instruct the feed to start. $this->feed->start(); // Check how much memory is available at first. - $initial_available_memory = MemoryManager::get_available_memory(); + $initial_available_memory = $this->memory_manager->get_available_memory(); do { - $result = $this->iterate( $args, $progress ? $progress->processed_batches + 1 : 1, $this->per_page ); + $result = $this->iterate( $this->query_args, $progress ? $progress->processed_batches + 1 : 1, $this->per_page ); $iterated = count( $result->products ); // Only done when the progress is not set. Will be modified otherwise. @@ -153,10 +212,17 @@ public function walk( ?callable $callback = null, array $additional_args = [] ): } // We don't want to use more than half of the available memory at the beginning of the script. - if ( $initial_available_memory - MemoryManager::get_available_memory() >= $initial_available_memory / 2 ) { - MemoryManager::flush_caches(); + $current_memory = $this->memory_manager->get_available_memory(); + if ( $initial_available_memory - $current_memory >= $initial_available_memory / 2 ) { + $this->memory_manager->flush_caches(); } - } while ( $iterated === $this->per_page ); + } while ( + // If `wc_get_products()` returns less than the batch size, it was the last page. + $iterated === $this->per_page + + // For the cases where the above is true, make sure that we do not exceed the total number of pages. + && $progress->processed_batches < $progress->total_batch_count + ); // Instruct the feed to end. $this->feed->end(); @@ -173,7 +239,7 @@ public function walk( ?callable $callback = null, array $additional_args = [] ): * @return object The result of the query. */ private function iterate( array $args = [], int $page = 1, int $limit = 100 ): object { - $result = wc_get_products( + $result = $this->product_loader->get_products( array_merge( $args, [ diff --git a/src/Integrations/IntegrationInterface.php b/src/Integrations/IntegrationInterface.php index 9bbf052..a0078ac 100644 --- a/src/Integrations/IntegrationInterface.php +++ b/src/Integrations/IntegrationInterface.php @@ -38,6 +38,10 @@ public function register_hooks(): void; /** * Activate the integration. * + * This method is called when the plugin is activated. + * If there is ever a setting that controls active integrations, + * this method might also be called when the integration is activated. + * * @return void */ public function activate(): void; @@ -45,10 +49,22 @@ public function activate(): void; /** * Deactivate the integration. * + * This method is called when the plugin is deactivated. + * If there is ever a setting that controls active integrations, + * this method might also be called when the integration is deactivated. + * * @return void */ public function deactivate(): void; + /** + * Get the query arguments for the product feed. + * + * @see wc_get_products() + * @return array The query arguments. + */ + public function get_product_feed_query_args(): array; + /** * Create a feed that is to be populated. * diff --git a/src/Integrations/OpenAi/OpenAiIntegration.php b/src/Integrations/OpenAi/OpenAiIntegration.php index e00bb95..d8bcc52 100644 --- a/src/Integrations/OpenAi/OpenAiIntegration.php +++ b/src/Integrations/OpenAi/OpenAiIntegration.php @@ -53,7 +53,7 @@ public function init( Container $container, Settings $settings ) { } /** - * Registers all needed hooks. + * {@inheritdoc} */ public function register_hooks(): void { $this->settings->register_hooks(); @@ -66,13 +66,7 @@ public function register_hooks(): void { } /** - * Activate the integration. - * - * This method is called when the plugin is activated. - * If there is ever a setting that controls active integrations, - * this method might also be called when the integration is activated. - * - * @return void + * {@inheritdoc} */ public function activate(): void { if ( ! as_has_scheduled_action( ScheduledActionManager::SCHEDULED_ACTION_HOOK ) ) { @@ -81,13 +75,7 @@ public function activate(): void { } /** - * Deactivate the integration. - * - * This method is called when the plugin is deactivated. - * If there is ever a setting that controls active integrations, - * this method might also be called when the integration is deactivated. - * - * @return void + * {@inheritdoc} */ public function deactivate(): void { // Clean up scheduled events using Action Scheduler. @@ -97,27 +85,30 @@ public function deactivate(): void { } /** - * Get the ID of the provider. - * - * @return string The ID of the provider. + * {@inheritdoc} */ public function get_id(): string { return 'openai'; } /** - * Create a feed that is to be populated. - * - * @return FeedInterface The feed. + * {@inheritdoc} + */ + public function get_product_feed_query_args(): array { + return [ + 'type' => [ 'simple', 'variation' ], + ]; + } + + /** + * {@inheritdoc} */ public function create_feed(): FeedInterface { return new JsonFileFeed( 'openai-feed' ); } /** - * Get the product mapper for the provider. - * - * @return ProductMapperInterface The product mapper. + * {@inheritdoc} */ public function get_product_mapper(): ProductMapperInterface { // Instantiate only when needed, meaning while generating feeds. @@ -125,9 +116,7 @@ public function get_product_mapper(): ProductMapperInterface { } /** - * Get the feed validator for the provider. - * - * @return FeedValidatorInterface The feed validator. + * {@inheritdoc} */ public function get_feed_validator(): FeedValidatorInterface { // Instantiate only when needed, meaning while generating feeds. @@ -135,9 +124,7 @@ public function get_feed_validator(): FeedValidatorInterface { } /** - * Get the push delivery method for the provider. - * - * @return FileDeliveryInterface The push delivery method. + * {@inheritdoc} */ public function get_push_delivery_method(): FileDeliveryInterface { return new PushFile( $this->settings->get_endpoint_url() ?? '' ); diff --git a/src/Integrations/OpenAi/ScheduledActionManager.php b/src/Integrations/OpenAi/ScheduledActionManager.php index 7ae39c2..61c3a57 100644 --- a/src/Integrations/OpenAi/ScheduledActionManager.php +++ b/src/Integrations/OpenAi/ScheduledActionManager.php @@ -67,11 +67,7 @@ public function scheduled_push(): void { } $feed = $this->openai_integration->create_feed(); - $walker = new ProductWalker( - $this->openai_integration->get_product_mapper(), - $this->openai_integration->get_feed_validator(), - $feed - ); + $walker = ProductWalker::from_integration( $this->openai_integration, $feed ); $walker->walk(); try { diff --git a/src/Integrations/OpenAi/Settings.php b/src/Integrations/OpenAi/Settings.php index b6237a0..8dd8503 100644 --- a/src/Integrations/OpenAi/Settings.php +++ b/src/Integrations/OpenAi/Settings.php @@ -43,20 +43,17 @@ public function get( string $key, $default_value = '' ) { switch ( $key ) { case 'privacy_url': - return function_exists( 'get_privacy_policy_url' ) - ? get_privacy_policy_url() - : $default_value; + $privacy_url = get_privacy_policy_url(); + return $privacy_url ? $privacy_url : $default_value; case 'tos_url': case 'returns_url': - return function_exists( 'wc_terms_and_conditions_page_id' ) - ? get_permalink( wc_terms_and_conditions_page_id() ) - : $default_value; + $returns_url = get_permalink( wc_terms_and_conditions_page_id() ); + return $returns_url ? $returns_url : $default_value; case 'seller_name': return get_bloginfo( 'name' ); case 'seller_url': - return function_exists( 'wc_get_page_permalink' ) - ? wc_get_page_permalink( 'shop' ) - : home_url(); + $seller_url = wc_get_page_permalink( 'shop' ); + return $seller_url ? $seller_url : $default_value; case 'endpoint_url': $key = 'feed_url'; // No break. @@ -144,7 +141,9 @@ public function extend_providers( array $providers, array $registry ): array { * @return array */ public function save_settings( array $registry ): array { - check_admin_referer( 'woocommerce-settings' ); + if ( ! defined( 'PRODUCT_FEED_UNIT_TESTS' ) || ! PRODUCT_FEED_UNIT_TESTS ) { + check_admin_referer( 'woocommerce-settings' ); + } // Feed URL. if ( isset( $_POST['woocommerce_agentic_openai_feed_url'] ) ) { diff --git a/src/Integrations/POSCatalog/ApiController.php b/src/Integrations/POSCatalog/ApiController.php index f94eb9d..3903bfb 100644 --- a/src/Integrations/POSCatalog/ApiController.php +++ b/src/Integrations/POSCatalog/ApiController.php @@ -79,7 +79,12 @@ public function is_authorized() { public function generate_feed( WP_REST_Request $request ) { $generator = $this->container->get( AsyncGenerator::class ); try { - $response = $request->get_param( 'force' ) ? $generator->force_regeneration() : $generator->get_status(); + $params = []; + $response = $request->get_param( 'force' ) + ? $generator->force_regeneration( $params ) + : $generator->get_status( $params ); + + // Remove sensitive data from the response. if ( isset( $response['action_id'] ) ) { unset( $response['action_id'] ); } diff --git a/src/Integrations/POSCatalog/AsyncGenerator.php b/src/Integrations/POSCatalog/AsyncGenerator.php index 16334d0..ad07786 100644 --- a/src/Integrations/POSCatalog/AsyncGenerator.php +++ b/src/Integrations/POSCatalog/AsyncGenerator.php @@ -9,6 +9,8 @@ namespace Automattic\WooCommerce\ProductFeedForOpenAI\Integrations\POSCatalog; +use ActionScheduler_AsyncRequest_QueueRunner; +use ActionScheduler_Store; use Automattic\WooCommerce\ProductFeedForOpenAI\Feed\ProductWalker; use Automattic\WooCommerce\ProductFeedForOpenAI\Feed\WalkerProgress; @@ -17,7 +19,7 @@ } /** - * Async Generator for the POS catalog. + * Async Generator for feeds. */ final class AsyncGenerator { /** @@ -25,21 +27,14 @@ final class AsyncGenerator { * * @var string */ - const FEED_GENERATION_ACTION = 'wpfoai_pos_catalog_feed_generation'; + const FEED_GENERATION_ACTION = 'wpfoai_feed_generation'; /** * The Action Scheduler action hook for the feed deletion. * * @var string */ - const FEED_DELETION_ACTION = 'wpfoai_pos_catalog_feed_deletion'; - - /** - * The option key for the feed generation status. - * - * @var string - */ - const OPTION_KEY = 'pos_feed_status'; + const FEED_DELETION_ACTION = 'wpfoai_feed_deletion'; /** * Feed expiry time, once completed. @@ -47,7 +42,7 @@ final class AsyncGenerator { * * @var int */ - const FEED_EXPIRY = 20 * MINUTE_IN_SECONDS; + const FEED_EXPIRY = 24 * HOUR_IN_SECONDS; /** * Possible states of generation. @@ -79,38 +74,62 @@ public function init( POSIntegration $integration ) { */ public function register_hooks(): void { add_action( self::FEED_GENERATION_ACTION, [ $this, 'feed_generation_action' ] ); - add_action( self::FEED_DELETION_ACTION, [ $this, 'feed_deletion_action' ] ); + add_action( self::FEED_DELETION_ACTION, [ $this, 'feed_deletion_action' ], 10, 2 ); } /** * Returns the current feed generation status. * Initiates one if not already running. * - * @return array The feed generation status. + * @param array|null $args The arguments to pass to the action. + * @return array The feed generation status. */ - public function get_status(): array { - $status = get_option( self::OPTION_KEY ); - - if ( false === $status ) { - // Clear all previous actions to avoid race conditions. - as_unschedule_all_actions( self::FEED_GENERATION_ACTION ); - - // Add a bit of delay to avoid race conditions. - $delay = 10; - $action_id = as_schedule_single_action( time() + $delay, self::FEED_GENERATION_ACTION, [] ); - - $status = [ - 'action_id' => $action_id, - 'state' => self::STATE_SCHEDULED, - 'progress' => 0, - 'processed' => 0, - 'total' => -1, - ]; - - update_option( - self::OPTION_KEY, - $status - ); + public function get_status( ?array $args = null ): array { + // Determine the option key based on the integration ID and arguments. + $option_key = $this->get_option_key( $args ); + $status = get_option( $option_key ); + + // For existing jobs, make sure that everything in the status makes sense. + if ( false !== $status && ! $this->validate_status( $status ) ) { + $status = false; + } + + // If the status is an array, it means that there is nothing to schedule in this method. + if ( false !== $status ) { + return $status; + } + + // Clear all previous actions to avoid race conditions. + as_unschedule_all_actions( self::FEED_GENERATION_ACTION, [ $option_key ], 'wpfoai' ); + + $status = [ + 'scheduled_at' => time(), + 'state' => self::STATE_SCHEDULED, + 'progress' => 0, + 'processed' => 0, + 'total' => -1, + 'args' => $args ?? [], + ]; + + update_option( + $option_key, + $status + ); + + // Start an immediate async action to generate the feed. + as_enqueue_async_action( + self::FEED_GENERATION_ACTION, + [ $option_key ], + 'wpfoai', + true, + 1 + ); + + // Manually force an async request to be dispatched to process the action immediately. + if ( class_exists( ActionScheduler_AsyncRequest_QueueRunner::class ) && class_exists( ActionScheduler_Store::class ) ) { + $store = ActionScheduler_Store::instance(); + $async_request = new ActionScheduler_AsyncRequest_QueueRunner( $store ); + $async_request->dispatch(); } return $status; @@ -119,10 +138,11 @@ public function get_status(): array { /** * Action scheduler callback for the feed generation. * + * @param string $option_key The option key for the feed generation status. * @return void */ - public function feed_generation_action() { - $status = get_option( self::OPTION_KEY ); + public function feed_generation_action( string $option_key ) { + $status = get_option( $option_key ); if ( ! is_array( $status ) || ! isset( $status['state'] ) || self::STATE_SCHEDULED !== $status['state'] ) { wc_get_logger()->error( 'Invalid feed generation status', [ 'status' => $status ] ); @@ -130,19 +150,15 @@ public function feed_generation_action() { } $status['state'] = self::STATE_IN_PROGRESS; - update_option( self::OPTION_KEY, $status ); + update_option( $option_key, $status ); $feed = $this->integration->create_feed(); - $walker = new ProductWalker( - $this->integration->get_product_mapper(), - $this->integration->get_feed_validator(), - $feed - ); + $walker = ProductWalker::from_integration( $this->integration, $feed ); $walker->walk( - function ( WalkerProgress $progress ) use ( &$status ) { + function ( WalkerProgress $progress ) use ( &$status, $option_key ) { $status = $this->update_feed_progress( $status, $progress ); - update_option( self::OPTION_KEY, $status ); + update_option( $option_key, $status ); } ); @@ -150,28 +166,35 @@ function ( WalkerProgress $progress ) use ( &$status ) { $status['state'] = self::STATE_COMPLETED; $status['url'] = $feed->get_file_url(); $status['path'] = $feed->get_file_path(); - update_option( self::OPTION_KEY, $status ); + update_option( $option_key, $status ); // Schedule another action to delete the file after the expiry time. as_schedule_single_action( time() + self::FEED_EXPIRY, self::FEED_DELETION_ACTION, - [ 'path' => $feed->get_file_path() ] + [ + $option_key, + $feed->get_file_path(), + ], + 'wpfoai', + true ); } /** * Forces a regeneration of the feed. * + * @param array|null $args The arguments to pass to the action. * @return array The feed generation status. * @throws \Exception When there is a reason why the regeneration cannot be forced. */ - public function force_regeneration(): array { - $status = get_option( self::OPTION_KEY ); + public function force_regeneration( ?array $args = null ): array { + $option_key = $this->get_option_key( $args ); + $status = get_option( $option_key ); - // If there is no option, there is nothing to force. - if ( false === $status ) { - return $this->get_status(); + // If there is no option, there is nothing to force. If the option is invalid, we can restart. + if ( false === $status || ! $this->validate_status( $status ) ) { + return $this->get_status( $args ); } switch ( $status['state'] ?? '' ) { @@ -186,8 +209,8 @@ public function force_regeneration(): array { case self::STATE_COMPLETED: // Delete the existing file, clear the option and let generation start again. wp_delete_file( $status['path'] ); - delete_option( self::OPTION_KEY ); - return $this->get_status(); + delete_option( $option_key ); + return $this->get_status( $args ); default: throw new \Exception( 'Unknown feed generation state.' ); @@ -197,13 +220,37 @@ public function force_regeneration(): array { /** * Action scheduler callback for the feed deletion after expiry. * - * @param array $args The arguments passed to the action. + * @param string $option_key The option key for the feed generation status. + * @param string $path The path to the feed file. * @return void */ - public function feed_deletion_action( array $args ) { - $path = $args['path']; + public function feed_deletion_action( string $option_key, string $path ) { + delete_option( $option_key ); wp_delete_file( $path ); - delete_option( self::OPTION_KEY ); + } + + /** + * Returns the option key for the feed generation status. + * + * @param array|null $args The arguments to pass to the action. + * @return string The option key. + */ + private function get_option_key( ?array $args = null ): string { + $normalized_args = $args ?? []; + if ( ! empty( $normalized_args ) ) { + ksort( $normalized_args ); + } + + return 'feed_status_' . md5( + // WPCS dislikes serialize for security reasons, but it will be hashed immediately. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + serialize( + [ + 'integration' => $this->integration->get_id(), + 'args' => $normalized_args, + ] + ) + ); } /** @@ -221,4 +268,52 @@ private function update_feed_progress( array $status, WalkerProgress $progress ) $status['total'] = $progress->total_count; return $status; } + + /** + * Validates the status of the feed generation. + * + * Makes sure that the file exists for completed jobs, + * that scheduled jobs are not stuck, etc. + * + * @param array $status The status of the feed generation. + * @return bool True if the status is valid, false otherwise. + */ + private function validate_status( array $status ): bool { + // Validate the state. + /** + * For completed jobs, make sure the file still exists. Regenerate otherwise. + * + * The file should typically get deleted at the same time as the status is cleared. + * However, something else could cause the file to disappear in the meantime (ex. manual delete). + */ + if ( self::STATE_COMPLETED === $status['state'] && ! file_exists( $status['path'] ) ) { + return false; + } + + /** + * If the job has been scheduled more than 10 minutes ago but has not + * transitioned to IN_PROGRESS yet, ActionScheduler is typically stuck. + */ + + /** + * Allows the timeout for a feed to remain in `scheduled` state to be changed. + * + * @param int $stuck_time The stuck time in seconds. + * @return int The stuck time in seconds. + * @since 0.1.0 + */ + $scheduled_timeout = apply_filters( 'wpfoai_scheduled_timeout', 10 * MINUTE_IN_SECONDS ); + if ( + self::STATE_SCHEDULED === $status['state'] + && ( + ! isset( $status['scheduled_at'] ) + || time() - $status['scheduled_at'] > $scheduled_timeout + ) + ) { + return false; + } + + // All good. + return true; + } } diff --git a/src/Integrations/POSCatalog/POSIntegration.php b/src/Integrations/POSCatalog/POSIntegration.php index bf8fb81..44b6a5d 100644 --- a/src/Integrations/POSCatalog/POSIntegration.php +++ b/src/Integrations/POSCatalog/POSIntegration.php @@ -39,18 +39,23 @@ public function init( Container $container ) { } /** - * Get the ID of the provider. - * - * @return string The ID of the provider. + * {@inheritdoc} */ public function get_id(): string { return 'pos'; } /** - * Register hooks for the integration. - * - * @return void + * {@inheritdoc} + */ + public function get_product_feed_query_args(): array { + return [ + 'type' => [ 'simple', 'variable', 'variation' ], + ]; + } + + /** + * {@inheritdoc} */ public function register_hooks(): void { add_action( 'rest_api_init', [ $this, 'rest_api_init' ] ); @@ -68,45 +73,35 @@ public function rest_api_init(): void { } /** - * Activate the integration. - * - * @return void + * {@inheritdoc} */ public function activate(): void { // At the moment, there are no activation steps for the POS catalog. } /** - * Deactivate the integration. - * - * @return void + * {@inheritdoc} */ public function deactivate(): void { // At the moment, there are no deactivation steps for the POS catalog. } /** - * Create a feed that is to be populated. - * - * @return FeedInterface The feed. + * {@inheritdoc} */ public function create_feed(): FeedInterface { return new JsonFileFeed( 'pos-catalog-feed' ); } /** - * Get the product mapper for the provider. - * - * @return ProductMapperInterface The product mapper. + * {@inheritdoc} */ public function get_product_mapper(): ProductMapperInterface { return $this->container->get( ProductMapper::class ); } /** - * Get the feed validator for the provider. - * - * @return FeedValidatorInterface The feed validator. + * {@inheritdoc} */ public function get_feed_validator(): FeedValidatorInterface { return $this->container->get( FeedValidator::class ); diff --git a/src/Storage/JsonFileFeed.php b/src/Storage/JsonFileFeed.php index 27c883e..2017bc8 100644 --- a/src/Storage/JsonFileFeed.php +++ b/src/Storage/JsonFileFeed.php @@ -101,9 +101,26 @@ public function start(): void { ); } - $file_name = wp_unique_filename( $directory, $this->base_name . '.json' ); - $this->file_path = $directory . $file_name; - $this->file_url = $upload_dir['baseurl'] . '/product-feeds/' . $file_name; + /** + * Allows the current time to be overridden before a feed is stored. + * + * @param int $time The current time. + * @param FeedInterface $feed The feed instance. + * @return int The current time. + * @since 0.1.0 + */ + $current_time = apply_filters( 'wpfoai_feed_time', time(), $this ); + $hash_data = $this->base_name . gmdate( 'r', $current_time ); + $file_name = sprintf( + '%s-%s-%s.json', + $this->base_name, + gmdate( 'Y-m-d', $current_time ), + wp_hash( $hash_data ) + ); + + $this->file_path = $directory . $file_name; + $this->file_url = $upload_dir['baseurl'] . '/product-feeds/' . $file_name; + $this->file_handle = fopen( $this->file_path, 'w' ); if ( false === $this->file_handle ) { diff --git a/src/Utils/MemoryManager.php b/src/Utils/MemoryManager.php index e608677..0f7f4c0 100644 --- a/src/Utils/MemoryManager.php +++ b/src/Utils/MemoryManager.php @@ -22,7 +22,7 @@ class MemoryManager { * * @return int Available memory as a percentage of the total memory limit. */ - public static function get_available_memory(): int { + public function get_available_memory(): int { $memory_limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) ); if ( -1 === $memory_limit ) { // Some systems have "unlimited" memory. @@ -35,7 +35,7 @@ public static function get_available_memory(): int { /** * Flush all caches caches. */ - public static function flush_caches() { + public function flush_caches() { global $wpdb, $wp_object_cache; $wpdb->queries = []; @@ -56,13 +56,13 @@ public static function flush_caches() { $wp_object_cache->__remoteset(); // important. } - self::collect_garbage(); + $this->collect_garbage(); } /** * Collect garbage. */ - public static function collect_garbage() { + private function collect_garbage() { static $gc_threshold = 5000; static $gc_too_low_in_a_row = 0; static $gc_too_high_in_a_row = 0; diff --git a/tests/unit/ProductFeed/Feed/ProductWalkerTest.php b/tests/unit/ProductFeed/Feed/ProductWalkerTest.php new file mode 100644 index 0000000..fa3fd23 --- /dev/null +++ b/tests/unit/ProductFeed/Feed/ProductWalkerTest.php @@ -0,0 +1,247 @@ + [ + 'number_of_products' => 0, + 'batch_size' => 10, + 'add_args_filter' => true, + ], + 'Single batch' => [ + 'number_of_products' => 10, + 'batch_size' => 100, + 'add_args_filter' => false, + ], + 'Multiple batches, half last batch' => [ + 'number_of_products' => 5 * 12 - 6, + 'batch_size' => 12, + 'add_args_filter' => true, + ], + 'Multiple batches, full last batch' => [ + 'number_of_products' => 5 * 13, + 'batch_size' => 13, + 'add_args_filter' => false, + ], + 'High number of batches, proper memory management' => [ + 'number_of_products' => 15 * 2, + 'batch_size' => 2, + 'add_args_filter' => false, + ], + ]; + } + + /** + * Test the product walker with varying input and results. + * + * @param int $number_of_products The number of products to generate. + * @param int $batch_size The batch size to use. + * @param bool $add_args_filter Whether the args filter is present. + * + * @dataProvider provider_walker + */ + public function test_walker( int $number_of_products, int $batch_size, bool $add_args_filter ) { + /** + * Prepare all mocked data. + */ + + // There should be at least one iteration, even with zero products. + $expected_iterations = max( 1, (int) ceil( $number_of_products / $batch_size ) ); + + // Generate products, group them into resulting batches. + $loader_results = []; + $generated_products = 0; + for ( $i = 0; $i < $expected_iterations; $i++ ) { + $page = []; + for ( $j = 1; $j <= $batch_size && $generated_products++ < $number_of_products; $j++ ) { + $page[] = WC_Helper_Product::create_simple_product(); + } + + $loader_results[] = (object) [ + 'products' => $page, + 'total' => $number_of_products, + 'max_num_pages' => $expected_iterations, + ]; + } + + // Additional parameters for the query. + $parent_exclude = -156; + $additional_query_args = [ + 'parent_exclude' => $parent_exclude, + 'category' => [ 'shirts' ], + ]; + + // The 11th product will always be rejected due to a validation error. + $validation_compensation = ( $number_of_products > 10 ? 1 : 0 ); + + /** + * Set up all dependencies, including mocks. + */ + $mock_loader = $this->createMock( ProductLoader::class ); + $this->test_container->replace_with_concrete( ProductLoader::class, $mock_loader ); + + $mock_memory_manager = $this->createMock( MemoryManager::class ); + $this->test_container->replace_with_concrete( MemoryManager::class, $mock_memory_manager ); + + $mock_feed = $this->createMock( FeedInterface::class ); + + // Setup everything that comes from the integration, and the integration itself. + $mock_mapper = $this->createMock( ProductMapperInterface::class ); + $mock_validator = $this->createMock( FeedValidatorInterface::class ); + $mock_integration = $this->createMock( IntegrationInterface::class ); + $mock_integration->expects( $this->once() )->method( 'get_product_mapper' )->willReturn( $mock_mapper ); + $mock_integration->expects( $this->once() )->method( 'get_feed_validator' )->willReturn( $mock_validator ); + + /** + * Set up data & expectations. + */ + $mock_integration->expects( $this->once() ) + ->method( 'get_product_feed_query_args' ) + ->willReturn( $additional_query_args ); + + // Set up the expectationf or each batch. + $loaded_page = 0; + $mock_loader->expects( $this->exactly( $expected_iterations ) ) + ->method( 'get_products' ) + ->with( + $this->callback( + function ( $args ) use ( &$loaded_page, $batch_size, $add_args_filter, $parent_exclude ) { + // Check pagination. + $this->assertEquals( ++$loaded_page, $args['page'] ); + $this->assertEquals( $batch_size, $args['limit'] ); + + // The argument coming from the factory method should be here. + $this->assertEquals( $parent_exclude, $args['parent_exclude'] ); + + // There would be a category, unless the filter removed it.. + if ( $add_args_filter ) { + $this->assertArrayNotHasKey( 'category', $args ); + } else { + $this->assertArrayHasKey( 'category', $args ); + $this->assertEquals( [ 'shirts' ], $args['category'] ); + } + return true; + } + ) + ) + ->willReturnCallback( + function () use ( &$loader_results ) { + return array_shift( $loader_results ); + } + ); + + // Set up the mapper. + $mock_mapper->expects( $this->exactly( $number_of_products ) ) + ->method( 'map_product' ) + ->with( $this->isInstanceOf( WC_Product::class ) ) + ->willReturnCallback( + function ( WC_Product $product ) { + return [ + 'id' => $product->get_id(), + ]; + } + ); + + // Set up the validator. + $validated_products = 0; + $mock_validator->expects( $this->exactly( $number_of_products ) ) + ->method( 'validate_entry' ) + ->with( $this->isType( 'array' ), $this->isInstanceOf( WC_Product::class ) ) + ->willReturnCallback( + function ( array $mapped_data, WC_Product $product ) use ( &$validated_products ) { + $this->assertEquals( $product->get_id(), $mapped_data['id'] ); + + // Pick a "random" product to invalidate. + $validated_products++; + if ( 11 === $validated_products ) { + return [ 'error' => 'Some validation error' ]; + } + return []; + } + ); + + // Make sure that the field is initiated, added to, and ended. + $mock_feed->expects( $this->once() )->method( 'start' ); + $mock_feed->expects( $this->once() )->method( 'end' ); + $mock_feed->expects( $this->exactly( $number_of_products - $validation_compensation ) ) + ->method( 'add_entry' ) + ->with( $this->isType( 'array' ) ); + + // Make sure that progress is indicated correctly. + $processed_iterations = 0; + $walker_callback = function ( WalkerProgress $progress ) use ( + $number_of_products, + $expected_iterations, + &$processed_iterations, + $batch_size, + ) { + $this->assertEquals( $number_of_products, $progress->total_count ); + $this->assertEquals( $expected_iterations, $progress->total_batch_count ); + $this->assertEquals( ++$processed_iterations, $progress->processed_batches ); + $this->assertEquals( min( $processed_iterations * $batch_size, $number_of_products ), $progress->processed_items ); + }; + + if ( $add_args_filter ) { + // Add a filter that unsets the category query arg. + add_filter( + 'wpfoai_product_feed_args', + function ( $args, $integration ) use ( $mock_integration ) { + $this->assertSame( $mock_integration, $integration ); + unset( $args['category'] ); + return $args; + }, + 10, + 2 + ); + } + + // Memory management: Always start with 90%. Eatch batch takes up 20%. + $available_memory = 90; + $mock_memory_manager->expects( $this->exactly( $expected_iterations + 1 ) ) + ->method( 'get_available_memory' ) + ->willReturnCallback( + function () use ( &$available_memory ) { + $available_memory -= 20; + return $available_memory; + } + ); + // Flushing cashes frees up memory up to 46% (just a bit over half). + // So once memory gets low, it remains just above the threshold (half of 90% or 45%). + $flushes = max( 0, $expected_iterations - 1 ); + $mock_memory_manager->expects( $this->exactly( $flushes ) ) + ->method( 'flush_caches' ) + ->willReturnCallback( + function () use ( &$available_memory ) { + $available_memory = 46; + } + ); + + /** + * Finally, get the walker and go! + */ + $walker = ProductWalker::from_integration( + $mock_integration, + $mock_feed + ); + + $walker->set_batch_size( $batch_size ); + $walker->walk( $walker_callback ); + } +} diff --git a/tests/unit/ProductFeed/Integrations/OpenAi/SettingsTest.php b/tests/unit/ProductFeed/Integrations/OpenAi/SettingsTest.php new file mode 100644 index 0000000..b02cef5 --- /dev/null +++ b/tests/unit/ProductFeed/Integrations/OpenAi/SettingsTest.php @@ -0,0 +1,185 @@ +sut = new Settings(); + } + + public function test_get_returns_privacy_url() { + // Fall back to the default option if there is no privacy policy page set. + $this->assertEquals( + 'https://example.com/', + $this->sut->get( 'privacy_url', 'https://example.com/' ) + ); + + // When there is a privacy policy page set, it should return the permalink. + // Use a random product, it will have a link too. + $product = WC_Helper_Product::create_simple_product(); + update_option( 'wp_page_for_privacy_policy', $product->get_id() ); + $this->assertEquals( + $product->get_permalink(), + $this->sut->get( 'privacy_url', 'https://example.com/' ) + ); + } + + public function test_get_returns_tos_and_returns_url() { + // Fall back to the default option if there is no T&C policy page set. + $this->assertEquals( + 'https://example.com/', + $this->sut->get( 'tos_url', 'https://example.com/' ) + ); + $this->assertEquals( + 'https://example.com/', + $this->sut->get( 'returns_url', 'https://example.com/' ) + ); + + // When there is a T&C policy page set, it should return the permalink. + // Use a random product, it will have a link too. + $product = WC_Helper_Product::create_simple_product(); + update_option( 'woocommerce_terms_page_id', $product->get_id() ); + $this->assertEquals( + $product->get_permalink(), + $this->sut->get( 'tos_url', 'https://example.com/' ) + ); + $this->assertEquals( + $product->get_permalink(), + $this->sut->get( 'returns_url', 'https://example.com/' ) + ); + } + + public function test_get_seller_name_returns_blog_name() { + $this->assertEquals( + get_bloginfo( 'name' ), + $this->sut->get( 'seller_name', 'https://example.com/' ) + ); + } + + public function test_get_returns_random_openai_setting() { + update_option( + 'woocommerce_agentic_agent_registry', + [ + 'openai' => [ + 'basic_setting' => 'xyz', + ], + 'general' => [ + 'basic_setting' => 'xyz', + 'another_setting' => 'abc', + ], + ] + ); + + $this->assertEquals( + 'xyz', + $this->sut->get( 'basic_setting' ) + ); + $this->assertEquals( + 'abc', + $this->sut->get( 'another_setting' ) + ); + } + + public function test_get_endpoint_url_returns_feed_url() { + update_option( + 'woocommerce_agentic_agent_registry', + [ + 'openai' => [ + 'feed_url' => 'https://example.com/', + ], + ] + ); + + $this->assertEquals( + 'https://example.com/', + $this->sut->get_endpoint_url() + ); + } + + public function test_get_endpoint_url_returns_null_if_no_feed_url_is_set() { + update_option( + 'woocommerce_agentic_agent_registry', + [ + 'openai' => [], + ] + ); + + $this->assertNull( $this->sut->get_endpoint_url() ); + } + + public function test_get_endpoint_url_respects_filter() { + update_option( + 'woocommerce_agentic_agent_registry', + [ + 'openai' => [ + 'feed_url' => 'https://example.com/', + ], + ] + ); + add_filter( 'wpfoai_openai_endpoint_url', fn() => 'https://example.com/filtered/' ); + $this->assertEquals( + 'https://example.com/filtered/', + $this->sut->get_endpoint_url() + ); + } + + public function test_extend_providers() { + $providers = [ + [ + 'id' => 'openai', + 'fields' => [ + [ + 'title' => 'Test Field', + 'id' => 'test_field', + ], + ], + ], + ]; + + $updated_providers = $this->sut->extend_providers( $providers, [] ); + $this->assertIsArray( $updated_providers ); + $this->assertContains( + 'test_field', + wp_list_pluck( $providers[0]['fields'], 'id' ), + 'Previously existing field remains' + ); + $this->assertNotCount( + 1, + $updated_providers[0]['fields'], + 'New fields are added' + ); + } + + public function test_save_settings() { + $registry = [ + 'openai' => [ + 'feed_url' => 'https://example.com/', + 'return_window' => 30, + ], + ]; + + $_POST['woocommerce_agentic_openai_feed_url'] = 'https://example.com/updated/'; + $_POST['woocommerce_agentic_openai_return_window'] = '60'; + + $updated_registry = $this->sut->save_settings( $registry ); + + $this->assertEquals( 'https://example.com/updated/', $updated_registry['openai']['feed_url'] ); + $this->assertEquals( 60, $updated_registry['openai']['return_window'] ); + } +} diff --git a/tests/unit/ProductFeed/ProductFeedTestCase.php b/tests/unit/ProductFeed/ProductFeedTestCase.php new file mode 100644 index 0000000..e251ea3 --- /dev/null +++ b/tests/unit/ProductFeed/ProductFeedTestCase.php @@ -0,0 +1,103 @@ +test_container = $this->get_replacement_container( + isset( $this->test_container ) ? $this->test_container : null + ); + } + + public function tearDown(): void { + parent::tearDown(); + + $this->test_container->reset_all_replacements(); + } + + /** + * Creates a mock object. + * + * This method does not work differently from `createMock`, + * but the DocBlock comment indicates a proper return type, + * combining `MockObject` and the provided class name. + * + * @template ID + * @param class-string $original_class_name Name of the class to mock. + * @return ID|MockObject + */ + // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found,Squiz.Commenting.FunctionComment.IncorrectTypeHint + public function createMock( string $original_class_name ): MockObject { + return parent::createMock( $original_class_name ); + } + + /** + * Get the replacement container. + * + * @param TestContainer|null $current_container The current test container. + * @return TestContainer The replacement container. + */ + private function get_replacement_container( ?TestContainer $current_container = null ): TestContainer { + // The same instance will be shared across all tests. + static $test_container; + + // Just return the test container if it is already set. + if ( isset( $test_container ) && $test_container === $current_container ) { + return $current_container; + } + + $plugin_instance = wpfoai_plugin(); + $plugin_property = $this->make_property_accessible( $plugin_instance, 'container' ); + $main_container = $plugin_property->getValue( $plugin_instance ); + + // If the test container is already used in the plugin, don't change anything. + if ( $main_container === $test_container ) { + return $test_container; + } + + // Fetch the `initial_resolved_cache` from the existing `RuntimeContainer`. + $container_property = $this->make_property_accessible( $main_container, 'container' ); + $runtime_container = $container_property->getValue( $main_container ); + $cache_property = $this->make_property_accessible( $runtime_container, 'initial_resolved_cache' ); + $original_cache = $cache_property->getValue( $runtime_container ); + + // Create a test container with the same initial cache. + $test_container = new TestContainer( $original_cache ); + $plugin_property->setValue( $plugin_instance, $test_container ); + + return $test_container; + } + + /** + * Makes a property accessible and returns it. + * + * @param object $obj The object to make the property accessible. + * @param string $property_name The name of the property to make accessible. + * @return ReflectionProperty The property. + */ + private function make_property_accessible( object $obj, string $property_name ): ReflectionProperty { + $reflection = new ReflectionClass( $obj ); + $property = $reflection->getProperty( $property_name ); + $property->setAccessible( true ); + return $property; + } +} diff --git a/tests/unit/ProductFeed/Storage/JsonFileFeedTest.php b/tests/unit/ProductFeed/Storage/JsonFileFeedTest.php new file mode 100644 index 0000000..ed520b8 --- /dev/null +++ b/tests/unit/ProductFeed/Storage/JsonFileFeedTest.php @@ -0,0 +1,137 @@ + $current_time ); + + // Make sure there is no directory and that it will be created. + $directory = $this->get_and_delete_dir(); + + $feed = new JsonFileFeed( 'test-feed' ); + $feed->start(); + $feed->end(); + + $path = $feed->get_file_path(); + $this->assertStringContainsString( 'product-feeds', $path ); + $this->assertStringContainsString( $directory, $path ); + $this->assertStringContainsString( gmdate( 'Y-m-d', $current_time ), $path ); + $this->assertStringContainsString( wp_hash( 'test-feed' . gmdate( 'r', $current_time ) ), $path ); + $this->assertTrue( file_exists( $path ) ); + $this->assertEquals( '[]', file_get_contents( $path ) ); + + $url = $feed->get_file_url(); + $this->assertNotNull( $url ); + $this->assertStringEndsWith( '.json', (string) $url ); + $this->assertStringContainsString( '/product-feeds/', (string) $url ); + } + + public function test_feed_file_is_created_with_entries() { + $data = [ + [ + 'name' => 'First Entry', + 'price' => 100, + ], + [ + 'name' => 'Second Entry', + 'price' => 333, + ], + ]; + + $feed = new JsonFileFeed( 'test-feed' ); + $feed->start(); + foreach ( $data as $entry ) { + $feed->add_entry( $entry ); + } + $feed->end(); + + $this->assertEquals( + wp_json_encode( $data ), + file_get_contents( $feed->get_file_path() ) + ); + } + + public function test_get_file_url_returns_null_if_not_completed() { + $feed = new JsonFileFeed( 'test-feed' ); + $feed->start(); + $this->assertNull( $feed->get_file_url() ); + $feed->end(); + } + + public function test_get_file_path_before_start_throws_type_error() { + $feed = new JsonFileFeed( 'test-feed' ); + $this->expectException( \TypeError::class ); + // Property is unset until start(); return type is string → TypeError. + $feed->get_file_path(); + } + + public function test_add_entry_before_start_throws_type_error() { + $feed = new JsonFileFeed( 'test-feed' ); + $this->expectException( \TypeError::class ); + $feed->add_entry( [ 'name' => 'oops' ] ); + } + + public function test_end_before_start_throws_type_error() { + $feed = new JsonFileFeed( 'test-feed' ); + $this->expectException( \TypeError::class ); + $feed->end(); + } + + public function test_start_throws_when_directory_cannot_be_created() { + // Ensure clean state then create a FILE where the directory should be. + $this->get_and_delete_dir(); + $uploads_dir = wp_upload_dir()['basedir']; + $block_path = $uploads_dir . '/product-feeds'; + + // Create a file to block directory creation. + file_put_contents( $block_path, 'blocking file' ); + + try { + $feed = new JsonFileFeed( 'test-feed' ); + $this->expectException( \Exception::class ); + $feed->start(); + } finally { + // Cleanup: remove blocking file. + if ( file_exists( $block_path ) && is_file( $block_path ) ) { + unlink( $block_path ); + } + } + } + + /** + * Gets the directory for feed files, but also deletes it. + * + * @return string The directory path. + */ + private function get_and_delete_dir(): string { + $directory = wp_upload_dir()['basedir'] . '/product-feeds'; + if ( is_dir( $directory ) ) { + global $wp_filesystem; + WP_Filesystem(); + $wp_filesystem->rmdir( $directory, true ); + } + return $directory; + } +} diff --git a/tests/unit/ProductFeed/TestContainer.php b/tests/unit/ProductFeed/TestContainer.php new file mode 100644 index 0000000..4ae37e1 --- /dev/null +++ b/tests/unit/ProductFeed/TestContainer.php @@ -0,0 +1,29 @@ +resolved_cache[ $class_name ] = $concrete; + } + + /** + * Reset the resolved cache, together with all replacements. + */ + public function reset_all_replacements(): void { + $this->resolved_cache = $this->initial_resolved_cache; + } +} diff --git a/tests/unit/ProductFeed/Utils/TestUtilsTest.php b/tests/unit/ProductFeed/Utils/TestUtilsTest.php new file mode 100644 index 0000000..d338247 --- /dev/null +++ b/tests/unit/ProductFeed/Utils/TestUtilsTest.php @@ -0,0 +1,54 @@ +test_container->get( StringHelper::class ); + $this->assertInstanceOf( StringHelper::class, $string_helper ); + + // Replace a class in the container with a specific instance. + $this->test_container->replace_with_concrete( StringHelper::class, $replacement_helper ); + $replaced = wpfoai_get_service( StringHelper::class ); + $this->assertEquals( $replacement_helper, $replaced ); + + // Set the particular implementation of an interface. + $integration = wpfoai_get_service( OpenAiIntegration::class ); + $this->test_container->replace_with_concrete( IntegrationInterface::class, $integration ); + $interface_instance = wpfoai_get_service( IntegrationInterface::class ); + $this->assertInstanceOf( OpenAiIntegration::class, $interface_instance ); + + // For unit tests, test replacement with a mock. + $mock_integration = $this->createMock( IntegrationInterface::class ); + $mock_integration->expects( $this->once() ) + ->method( 'get_id' ) + ->willReturn( 'openai' ); + $this->test_container->replace_with_concrete( IntegrationInterface::class, $mock_integration ); + $this->assertSame( $mock_integration, wpfoai_get_service( IntegrationInterface::class ) ); + $this->assertEquals( 'openai', wpfoai_get_service( IntegrationInterface::class )->get_id() ); + + // Reset all replacements. + $this->test_container->reset_all_replacements(); + + // Expect an exception, as the interface does not have a specified implementation. + $this->expectException( ContainerException::class ); + $integration = wpfoai_get_service( IntegrationInterface::class ); + } +} diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php index daaa63e..5140957 100644 --- a/tests/unit/bootstrap.php +++ b/tests/unit/bootstrap.php @@ -3,6 +3,9 @@ ini_set( 'display_errors', '1' ); error_reporting( E_ALL ); +// Define a constnat to use in tests. +define( 'PRODUCT_FEED_UNIT_TESTS', true ); + // Let wp-phpunit tell us where the WP test suite lives. $_tests_dir = getenv( 'WP_TESTS_DIR' ); if ( ! $_tests_dir ) { @@ -103,4 +106,8 @@ function initialize_dependency_injection() { $GLOBALS['wc_container'] = $inner_container; } -initialize_dependency_injection(); \ No newline at end of file +initialize_dependency_injection(); + +// Include ProductFeed core test files. +require_once __DIR__ . '/ProductFeed/ProductFeedTestCase.php'; +require_once __DIR__ . '/ProductFeed/TestContainer.php';