Commit fd75e24dbc for woocommerce
commit fd75e24dbcd96e1abd0302145598d90947435d10
Author: Radoslav Georgiev <rageorgiev@gmail.com>
Date: Thu Dec 11 21:20:58 2025 +0200
Add the initial set of POS catalog functionality (#62313)
* Add the initial set of functionality, without ACP support
* Remove wpfoai naming
* Fix textdomains
* Update the controller namespace
* Add changefile(s) from automation for the following project(s): woocommerce
* Add tests
* Update the APIController and test to use the container correctly.
* Update @since tags
* Fix incorrect file comment
* PHPCS fixes
* Fix a ton of linting incompatibilities.
* Addressing since tags and trailing parameter commas.
* Rename an old `oapfw` filte rname.
* Addressing all PHPStan errors.
* Fix a short comment.
* Addressing wrong strings in tests.
* Address JsonFileFeedTest comments.
* Address ProductWalker and ApiController feedback.
* Add failure handling during generation
* Fix a spacing issue.
* Address PHPStan errors individually rather than adjusting baseline.
* Treat 0 memory limit as unlimited as well
* Allow extensions to register integrations
* Rename endpoint
---------
Co-authored-by: github-actions <github-actions@github.com>
diff --git a/plugins/woocommerce/changelog/62313-pay-436-move-pos-catalog-functionality-into-woocommerce-core b/plugins/woocommerce/changelog/62313-pay-436-move-pos-catalog-functionality-into-woocommerce-core
new file mode 100644
index 0000000000..64f49b42aa
--- /dev/null
+++ b/plugins/woocommerce/changelog/62313-pay-436-move-pos-catalog-functionality-into-woocommerce-core
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a new controller for generating a product feed for the Woo mobile app.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index b1825125db..a677085e3f 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -377,6 +377,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
$container->get( Automattic\WooCommerce\Internal\Fulfillments\FulfillmentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
// Classes inheriting from RestApiControllerBase.
$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index f078f14461..f6e5c7a10a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -103709,4 +103709,3 @@ parameters:
identifier: return.type
count: 1
path: woocommerce.php
-
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedInterface.php
new file mode 100644
index 0000000000..a363268f65
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedInterface.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Feed Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Feed Interface.
+ *
+ * @since 10.5.0
+ */
+interface FeedInterface {
+ /**
+ * Start the feed.
+ * This can create an empty file, eventually put something in it, or add a database entry.
+ *
+ * @return void
+ */
+ public function start(): void;
+
+ /**
+ * Add an entry to the feed.
+ *
+ * @param array $entry The entry to add.
+ * @return void
+ */
+ public function add_entry( array $entry ): void;
+
+ /**
+ * End the feed.
+ *
+ * @return void
+ */
+ public function end(): void;
+
+ /**
+ * Get the file path of the feed.
+ *
+ * @return string|null The path to the feed file, null if not ready.
+ */
+ public function get_file_path(): ?string;
+
+ /**
+ * Get the URL of the feed file.
+ *
+ * @return string|null The URL of the feed file, null if not ready.
+ */
+ public function get_file_url(): ?string;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedValidatorInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedValidatorInterface.php
new file mode 100644
index 0000000000..14d3061b8b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedValidatorInterface.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Feed Validator Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Feed Validator Interface.
+ *
+ * @since 10.5.0
+ */
+interface FeedValidatorInterface {
+ /**
+ * Validate a single entry.
+ *
+ * @param array $row The entry to validate.
+ * @param \WC_Product $product The related product. Will be updated with validation status.
+ * @return string[] Validation issues.
+ */
+ public function validate_entry( array $row, \WC_Product $product ): array;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductLoader.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductLoader.php
new file mode 100644
index 0000000000..a4dd8811b1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductLoader.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Product Loader class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Loader for products.
+ *
+ * @since 10.5.0
+ */
+class ProductLoader {
+ /**
+ * Retrieves products from WooCommerce.
+ *
+ * @since 10.5.0
+ *
+ * @see wc_get_products()
+ *
+ * @param array $args The arguments to pass to wc_get_products().
+ * @return array|\stdClass Number of pages and an array of product objects if
+ * paginate is true, or just an array of values.
+ */
+ public function get_products( array $args ) {
+ return wc_get_products( $args );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
new file mode 100644
index 0000000000..8a07a1721b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Product Mapper Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Product Mapper Interface.
+ *
+ * @since 10.5.0
+ */
+interface ProductMapperInterface {
+ /**
+ * Map a product to a feed row.
+ *
+ * @param \WC_Product $product The product to map.
+ * @return array The feed row.
+ */
+ public function map_product( \WC_Product $product ): array;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
new file mode 100644
index 0000000000..cfc80bd6d4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
@@ -0,0 +1,288 @@
+<?php
+/**
+ * Product Walker class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Walker for products.
+ *
+ * @since 10.5.0
+ */
+class ProductWalker {
+ /**
+ * The product loader.
+ *
+ * @var ProductLoader
+ */
+ private ProductLoader $product_loader;
+
+ /**
+ * The product mapper.
+ *
+ * @var ProductMapperInterface
+ */
+ private ProductMapperInterface $mapper;
+
+ /**
+ * The feed.
+ *
+ * @var FeedInterface
+ */
+ private FeedInterface $feed;
+
+ /**
+ * The feed validator.
+ *
+ * @var FeedValidatorInterface
+ */
+ private FeedValidatorInterface $validator;
+
+ /**
+ * The memory manager.
+ *
+ * @var MemoryManager
+ */
+ private MemoryManager $memory_manager;
+
+ /**
+ * The number of products to iterate through per batch.
+ *
+ * @var int
+ */
+ private int $per_page = 100;
+
+ /**
+ * The time limit to extend the execution time limit per batch.
+ *
+ * @var int
+ */
+ private int $time_limit = 0;
+
+ /**
+ * The query arguments to apply to the product query.
+ *
+ * @var array
+ */
+ private array $query_args;
+
+ /**
+ * Class constructor.
+ *
+ * This class will not be available through DI. Instead, it needs to be instantiated directly.
+ *
+ * @param ProductMapperInterface $mapper The product mapper.
+ * @param FeedValidatorInterface $validator The feed validator.
+ * @param FeedInterface $feed The feed.
+ * @param ProductLoader $product_loader The product loader.
+ * @param MemoryManager $memory_manager The memory manager.
+ * @param array $query_args The query arguments.
+ */
+ private function __construct(
+ ProductMapperInterface $mapper,
+ FeedValidatorInterface $validator,
+ FeedInterface $feed,
+ ProductLoader $product_loader,
+ MemoryManager $memory_manager,
+ array $query_args
+ ) {
+ $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.).
+ *
+ * @since 10.5.0
+ *
+ * @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(
+ array(
+ 'status' => array( '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 10.5.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(
+ 'woocommerce_product_feed_args',
+ $query_args,
+ $integration
+ );
+
+ $instance = new self(
+ $integration->get_product_mapper(),
+ $integration->get_feed_validator(),
+ $feed,
+ wc_get_container()->get( ProductLoader::class ),
+ wc_get_container()->get( MemoryManager::class ),
+ $query_args
+ );
+
+ return $instance;
+ }
+
+ /**
+ * Set the number of products to iterate through per batch.
+ *
+ * @since 10.5.0
+ *
+ * @param int $batch_size The number of products to iterate through per batch.
+ * @return self
+ */
+ public function set_batch_size( int $batch_size ): self {
+ if ( $batch_size < 1 ) {
+ $batch_size = 1;
+ }
+
+ $this->per_page = $batch_size;
+ return $this;
+ }
+
+ /**
+ * Set the time limit to extend the execution time limit per batch.
+ *
+ * @since 10.5.0
+ *
+ * @param int $time_limit Time limit in seconds.
+ * @return self
+ */
+ public function add_time_limit( int $time_limit ): self {
+ if ( $time_limit < 0 ) {
+ $time_limit = 0;
+ }
+
+ $this->time_limit = $time_limit;
+ return $this;
+ }
+
+ /**
+ * Walks through all products.
+ *
+ * @since 10.5.0
+ *
+ * @param callable $callback The callback to call after each batch of products is processed.
+ * @return int The total number of products processed.
+ */
+ public function walk( ?callable $callback = null ): int {
+ $progress = null;
+
+ // Instruct the feed to start.
+ $this->feed->start();
+
+ // Check how much memory is available at first.
+ $initial_available_memory = $this->memory_manager->get_available_memory();
+
+ do {
+ $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.
+ if ( is_null( $progress ) ) {
+ $progress = WalkerProgress::from_wc_get_products_result( $result );
+ }
+ $progress->processed_items += $iterated;
+ ++$progress->processed_batches;
+
+ if ( is_callable( $callback ) && $iterated > 0 ) {
+ $callback( $progress );
+ }
+
+ if ( $this->time_limit > 0 ) {
+ set_time_limit( $this->time_limit );
+ }
+
+ // We don't want to use more than half of the available memory at the beginning of the script.
+ $current_memory = $this->memory_manager->get_available_memory();
+ if ( $initial_available_memory - $current_memory >= $initial_available_memory / 2 ) {
+ $this->memory_manager->flush_caches();
+ }
+ } 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();
+
+ return $progress->processed_items;
+ }
+
+ /**
+ * Iterates through a batch of products.
+ *
+ * @param array $args The arguments to pass to wc_get_products().
+ * @param int $page The page number to iterate through.
+ * @param int $limit The maximum number of products to iterate through.
+ * @return \stdClass The result of the query with properties: products, total, max_num_pages.
+ */
+ private function iterate( array $args = array(), int $page = 1, int $limit = 100 ): \stdClass {
+ /**
+ * Result is always stdClass when paginate=true.
+ *
+ * @var \stdClass $result
+ */
+ $result = $this->product_loader->get_products(
+ array_merge(
+ $args,
+ array(
+ 'page' => $page,
+ 'limit' => $limit,
+ 'paginate' => true,
+ )
+ )
+ );
+
+ foreach ( $result->products as $product ) {
+ $mapped_data = $this->mapper->map_product( $product );
+
+ if ( ! empty( $this->validator->validate_entry( $mapped_data, $product ) ) ) {
+ continue;
+ }
+
+ $this->feed->add_entry( $mapped_data );
+ }
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php
new file mode 100644
index 0000000000..4955982b3b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Walker Progress class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Simple class that tracks/indicates the progress of a walker.
+ *
+ * @since 10.5.0
+ */
+final class WalkerProgress {
+ /**
+ * Total number of items to process.
+ *
+ * @var int
+ */
+ public int $total_count;
+
+ /**
+ * Total number of batches to process.
+ *
+ * @var int
+ */
+ public int $total_batch_count;
+
+ /**
+ * Number of items processed so far.
+ *
+ * @var int
+ */
+ public int $processed_items = 0;
+
+ /**
+ * Number of batches processed so far.
+ *
+ * @var int
+ */
+ public int $processed_batches = 0;
+
+ /**
+ * Creates a WalkerProgress instance from a WooCommerce products query result.
+ *
+ * @since 10.5.0
+ *
+ * @param \stdClass $result The result object from wc_get_products() with total and max_num_pages properties.
+ * @return self
+ */
+ public static function from_wc_get_products_result( \stdClass $result ): self {
+ $progress = new self();
+
+ $progress->total_count = $result->total;
+ $progress->total_batch_count = $result->max_num_pages;
+ $progress->processed_items = 0;
+ $progress->processed_batches = 0;
+
+ return $progress;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
new file mode 100644
index 0000000000..32e97d1978
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Interface that should be implemented by all provider integrations.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * IntegrationInterface
+ *
+ * @since 10.5.0
+ */
+interface IntegrationInterface {
+ /**
+ * Get the ID of the provider.
+ *
+ * @return string The ID of the provider.
+ */
+ public function get_id(): string;
+
+ /**
+ * Register hooks for the integration.
+ *
+ * @return void
+ */
+ 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;
+
+ /**
+ * 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.
+ *
+ * @return FeedInterface The feed.
+ */
+ public function create_feed(): FeedInterface;
+
+ /**
+ * Get the product mapper for the provider.
+ *
+ * @return ProductMapperInterface The product mapper.
+ */
+ public function get_product_mapper(): ProductMapperInterface;
+
+ /**
+ * Get the feed validator for the provider.
+ *
+ * @return FeedValidatorInterface The feed validator.
+ */
+ public function get_feed_validator(): FeedValidatorInterface;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationRegistry.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationRegistry.php
new file mode 100644
index 0000000000..42f45491a6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationRegistry.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Integration Registry class.
+ *
+ * Stores all provider integrations that are available.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * IntegrationRegistry
+ *
+ * @since 10.5.0
+ */
+class IntegrationRegistry {
+ /**
+ * List of all available Integrations.
+ *
+ * @var array<string,IntegrationInterface>
+ */
+ private array $integrations = array();
+
+ /**
+ * Register an Integration.
+ *
+ * @since 10.5.0
+ *
+ * @param IntegrationInterface $integration The integration to register.
+ */
+ public function register_integration( IntegrationInterface $integration ): void {
+ $this->integrations[ $integration->get_id() ] = $integration;
+ }
+
+ /**
+ * Get an Integration by ID.
+ *
+ * @since 10.5.0
+ *
+ * @param string $id The ID of the Integration.
+ * @return IntegrationInterface|null The Integration, or null if it is not registered.
+ */
+ public function get_integration( string $id ): ?IntegrationInterface {
+ return $this->integrations[ $id ] ?? null;
+ }
+
+ /**
+ * Get all registered integrations.
+ *
+ * @since 10.5.0
+ *
+ * @return array<string,IntegrationInterface>
+ */
+ public function get_integrations(): array {
+ return $this->integrations;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
new file mode 100644
index 0000000000..fca3ab01ed
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * POS Catalog API Controller.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Container;
+use WP_REST_Request;
+use WP_REST_Response;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * POS Catalog API Controller.
+ *
+ * @since 10.5.0
+ */
+class ApiController {
+ const ROUTE_NAMESPACE = 'wc/pos/v1/catalog';
+
+ /**
+ * Container instance.
+ *
+ * @var Container
+ */
+ private $container;
+
+ /**
+ * Dependency injector.
+ *
+ * @param Container $container The container instance. Everything else will be dynamic.
+ * @internal
+ */
+ final public function init( Container $container ): void {
+ $this->container = $container;
+ }
+
+ /**
+ * Register the routes for the API controller.
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ self::ROUTE_NAMESPACE,
+ '/create',
+ array(
+ 'methods' => 'POST',
+ 'callback' => array( $this, 'generate_feed' ),
+ 'permission_callback' => array( $this, 'is_authorized' ),
+ 'args' => array(
+ 'force' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => 'Force regeneration of the feed. NOOP if generation is in progress.',
+ ),
+ '_product_fields' => array(
+ 'type' => 'string',
+ 'description' => 'Comma-separated list of fields to include for non-variable products.',
+ 'required' => false,
+ ),
+ '_variation_fields' => array(
+ 'type' => 'string',
+ 'description' => 'Comma-separated list of fields to include for variations.',
+ 'required' => false,
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Checks if the current user has the necessary permissions to access the API.
+ *
+ * @return bool True if the user has the necessary permissions, false otherwise.
+ */
+ public function is_authorized() {
+ return is_user_logged_in() && (
+ current_user_can( 'manage_woocommerce' ) || current_user_can( 'manage_options' )
+ );
+ }
+
+ /**
+ * Starts generating a feed.
+ *
+ * @param WP_REST_Request<array<string, mixed>> $request The request object.
+ * @return WP_REST_Response The response object.
+ */
+ public function generate_feed( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+ $generator = $this->container->get( AsyncGenerator::class );
+ try {
+ $params = array();
+ if ( null !== $request['_product_fields'] ) {
+ $params['_product_fields'] = $request['_product_fields'];
+ }
+ if ( null !== $request['_variation_fields'] ) {
+ $params['_variation_fields'] = $request['_variation_fields'];
+ }
+
+ $response = $request->get_param( 'force' )
+ ? $generator->force_regeneration( $params )
+ : $generator->get_status( $params );
+
+ // Use the right datetime format.
+ if ( isset( $response['scheduled_at'] ) ) {
+ $response['scheduled_at'] = wc_rest_prepare_date_response( $response['scheduled_at'] );
+ }
+ if ( isset( $response['completed_at'] ) ) {
+ $response['completed_at'] = wc_rest_prepare_date_response( $response['completed_at'] );
+ }
+
+ // Remove sensitive data from the response.
+ if ( isset( $response['action_id'] ) ) {
+ unset( $response['action_id'] );
+ }
+ if ( isset( $response['path'] ) ) {
+ unset( $response['path'] );
+ }
+ } catch ( \Exception $e ) {
+ wc_get_logger()->error(
+ 'Feed generation failed',
+ array( 'error' => $e->getMessage() )
+ );
+ return new WP_REST_Response(
+ array(
+ 'success' => false,
+ 'message' => __( 'An error occurred while generating the feed.', 'woocommerce' ),
+ ),
+ 500
+ );
+ }
+ return new WP_REST_Response( $response );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
new file mode 100644
index 0000000000..553342fa1e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
@@ -0,0 +1,374 @@
+<?php
+/**
+ * Async Generator class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use ActionScheduler_AsyncRequest_QueueRunner;
+use ActionScheduler_Store;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\WalkerProgress;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Async Generator for feeds.
+ *
+ * @since 10.5.0
+ */
+class AsyncGenerator {
+ /**
+ * The Action Scheduler action hook for the feed generation.
+ *
+ * @var string
+ */
+ const FEED_GENERATION_ACTION = 'woocommerce_product_feed_generation';
+
+ /**
+ * The Action Scheduler action hook for the feed deletion.
+ *
+ * @var string
+ */
+ const FEED_DELETION_ACTION = 'woocommerce_product_feed_deletion';
+
+ /**
+ * Feed expiry time, once completed.
+ * If the feed is not downloaded within this timeframe, a new one will need to be generated.
+ *
+ * @var int
+ */
+ const FEED_EXPIRY = 24 * HOUR_IN_SECONDS;
+
+ /**
+ * Possible states of generation.
+ */
+ const STATE_SCHEDULED = 'scheduled';
+ const STATE_IN_PROGRESS = 'in_progress';
+ const STATE_COMPLETED = 'completed';
+ const STATE_FAILED = 'failed';
+
+ /**
+ * Integration instance.
+ *
+ * @var POSIntegration
+ */
+ private $integration;
+
+ /**
+ * Dependency injector.
+ *
+ * @param POSIntegration $integration The integration instance.
+ * @internal
+ */
+ final public function init( POSIntegration $integration ): void {
+ $this->integration = $integration;
+ }
+
+ /**
+ * Register hooks for the async generator.
+ *
+ * @since 10.5.0
+ *
+ * @return void
+ */
+ public function register_hooks(): void {
+ add_action( self::FEED_GENERATION_ACTION, array( $this, 'feed_generation_action' ) );
+ add_action( self::FEED_DELETION_ACTION, array( $this, 'feed_deletion_action' ), 10, 2 );
+ }
+
+ /**
+ * Returns the current feed generation status.
+ * Initiates one if not already running.
+ *
+ * @since 10.5.0
+ *
+ * @param array|null $args The arguments to pass to the action.
+ * @return array The feed generation 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 ( is_array( $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 ( is_array( $status ) ) {
+ return $status;
+ }
+
+ // Clear all previous actions to avoid race conditions.
+ as_unschedule_all_actions( self::FEED_GENERATION_ACTION, array( $option_key ), 'woo-product-feed' ); // @phpstan-ignore function.notFound
+
+ $status = array(
+ 'scheduled_at' => time(),
+ 'completed_at' => null,
+ 'state' => self::STATE_SCHEDULED,
+ 'progress' => 0,
+ 'processed' => 0,
+ 'total' => -1,
+ 'args' => $args ?? array(),
+ );
+
+ update_option(
+ $option_key,
+ $status
+ );
+
+ // Start an immediate async action to generate the feed.
+ // @phpstan-ignore-next-line function.notFound -- Action Scheduler.
+ as_enqueue_async_action(
+ self::FEED_GENERATION_ACTION,
+ array( $option_key ),
+ 'woo-product-feed',
+ 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;
+ }
+
+ /**
+ * Action scheduler callback for the feed generation.
+ *
+ * @since 10.5.0
+ *
+ * @param string $option_key The option key for the feed generation status.
+ * @return void
+ */
+ 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', array( 'status' => $status ) );
+ return;
+ }
+
+ $status['state'] = self::STATE_IN_PROGRESS;
+ update_option( $option_key, $status );
+
+ try {
+ $feed = $this->integration->create_feed();
+ $walker = ProductWalker::from_integration( $this->integration, $feed );
+
+ // Add dynamic args to the mapper.
+ $args = $status['args'] ?? array();
+ if (
+ isset( $args['_product_fields'] )
+ && is_string( $args['_product_fields'] ) &&
+ ! empty( $args['_product_fields'] )
+ ) {
+ $this->integration->get_product_mapper()->set_fields( $args['_product_fields'] );
+ }
+ if (
+ isset( $args['_variation_fields'] )
+ && is_string( $args['_variation_fields'] ) &&
+ ! empty( $args['_variation_fields'] )
+ ) {
+ $this->integration->get_product_mapper()->set_variation_fields( $args['_variation_fields'] );
+ }
+
+ $walker->walk(
+ function ( WalkerProgress $progress ) use ( &$status, $option_key ) {
+ $status = $this->update_feed_progress( $status, $progress );
+ update_option( $option_key, $status );
+ }
+ );
+
+ // Store the final details.
+ $status['state'] = self::STATE_COMPLETED;
+ $status['url'] = $feed->get_file_url();
+ $status['path'] = $feed->get_file_path();
+ $status['completed_at'] = time();
+ update_option( $option_key, $status );
+
+ // Schedule another action to delete the file after the expiry time.
+ // @phpstan-ignore-next-line function.notFound -- Action Scheduler.
+ as_schedule_single_action(
+ time() + self::FEED_EXPIRY,
+ self::FEED_DELETION_ACTION,
+ array(
+ $option_key,
+ $feed->get_file_path(),
+ ),
+ 'woo-product-feed',
+ true
+ );
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->error(
+ 'Feed generation failed',
+ array(
+ 'error' => $e->getMessage(),
+ 'option_key' => $option_key,
+ )
+ );
+
+ $status['state'] = self::STATE_FAILED;
+ $status['error'] = $e->getMessage();
+ $status['failed_at'] = time();
+ update_option( $option_key, $status );
+ }
+ }
+
+ /**
+ * Forces a regeneration of the feed.
+ *
+ * @since 10.5.0
+ *
+ * @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 $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 the option is invalid, we can restart.
+ if ( ! is_array( $status ) || ! $this->validate_status( $status ) ) {
+ return $this->get_status( $args );
+ }
+
+ switch ( $status['state'] ?? '' ) {
+ case self::STATE_SCHEDULED:
+ // If generation is scheduled, we can just let it be and return the current status.
+ // It should start shortly.
+ return $status;
+
+ case self::STATE_IN_PROGRESS:
+ throw new \Exception( 'Feed generation is already in progress and cannot be stopped.' );
+
+ case self::STATE_COMPLETED:
+ // Delete the existing file, clear the option and let generation start again.
+ wp_delete_file( (string) $status['path'] );
+ delete_option( $option_key );
+ return $this->get_status( $args );
+
+ case self::STATE_FAILED:
+ // Clear the failed status and restart generation.
+ delete_option( $option_key );
+ return $this->get_status( $args );
+
+ default:
+ throw new \Exception( 'Unknown feed generation state.' );
+ }
+ }
+
+ /**
+ * Action scheduler callback for the feed deletion after expiry.
+ *
+ * @since 10.5.0
+ *
+ * @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( string $option_key, string $path ) {
+ delete_option( $option_key );
+ wp_delete_file( $path );
+ }
+
+ /**
+ * 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 ?? array();
+ 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(
+ array(
+ 'integration' => $this->integration->get_id(),
+ 'args' => $normalized_args,
+ )
+ )
+ );
+ }
+
+ /**
+ * Updates the feed progress while the feed is being generated.
+ *
+ * @param array $status The last previously known status.
+ * @param WalkerProgress $progress The progress of the walker.
+ * @return array Updated status of the feed generation.
+ */
+ private function update_feed_progress( array $status, WalkerProgress $progress ): array {
+ $status['progress'] = $progress->total_count > 0
+ ? round( ( $progress->processed_items / $progress->total_count ) * 100, 2 )
+ : 0;
+ $status['processed'] = $progress->processed_items;
+ $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 10.5.0
+ */
+ $scheduled_timeout = apply_filters( 'woocommerce_product_feed_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/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/FeedValidator.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/FeedValidator.php
new file mode 100644
index 0000000000..da116097ae
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/FeedValidator.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Feed Validator class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Simple field validator for the POS catalog.
+ *
+ * @since 10.5.0
+ */
+final class FeedValidator implements FeedValidatorInterface {
+ /**
+ * Validate single feed row using schema.
+ *
+ * @param array $entry Product data row to validate.
+ * @param \WC_Product $product The related product. Will be updated with validation status.
+ * @return array Array of validation issues.
+ */
+ public function validate_entry( array $entry, \WC_Product $product ): array { //phpcs:ignore VariableAnalysis
+ return array();
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php
new file mode 100644
index 0000000000..00fdda9f97
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * POS Catalog Integration class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Container;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Storage\JsonFileFeed;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * POS Catalog Integration
+ *
+ * @since 10.5.0
+ */
+class POSIntegration implements IntegrationInterface {
+ /**
+ * Container instance.
+ *
+ * @var Container
+ */
+ private Container $container;
+
+ /**
+ * Dependency injector.
+ *
+ * @param Container $container Dependency container.
+ * @internal
+ */
+ final public function init( Container $container ): void {
+ $this->container = $container;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get_id(): string {
+ return 'pos';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get_product_feed_query_args(): array {
+ return array(
+ 'type' => array( 'simple', 'variable', 'variation' ),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function register_hooks(): void {
+ add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
+ $this->container->get( AsyncGenerator::class )->register_hooks();
+ }
+
+ /**
+ * Initialize the REST API.
+ *
+ * @return void
+ */
+ public function rest_api_init(): void {
+ // Only load the controller when necessary.
+ $this->container->get( ApiController::class )->register_routes();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function activate(): void {
+ // At the moment, there are no activation steps for the POS catalog.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deactivate(): void {
+ // At the moment, there are no deactivation steps for the POS catalog.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function create_feed(): FeedInterface {
+ return new JsonFileFeed( 'pos-catalog-feed' );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get_product_mapper(): ProductMapper {
+ return $this->container->get( ProductMapper::class );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get_feed_validator(): FeedValidatorInterface {
+ return $this->container->get( FeedValidator::class );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
new file mode 100644
index 0000000000..52a706750e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
@@ -0,0 +1,200 @@
+<?php
+/**
+ * ProductMapper class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use WC_Product;
+use WC_REST_Products_Controller;
+use WC_REST_Product_Variations_Controller;
+use WP_REST_Request;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Product Mapper for the POS catalog.
+ *
+ * Uses WooCommerce REST API controllers to map product data.
+ *
+ * @since 10.5.0
+ */
+class ProductMapper implements ProductMapperInterface {
+ /**
+ * Fields to include in the product mapping.
+ *
+ * @var string|null Fields to include in the product mapping.
+ */
+ private ?string $fields = null;
+
+ /**
+ * Fields to include in the variation mapping.
+ *
+ * @var string|null Fields to include in the variation mapping.
+ */
+ private ?string $variation_fields = null;
+
+ /**
+ * REST controller instance for products.
+ *
+ * @var WC_REST_Products_Controller|null
+ */
+ private ?WC_REST_Products_Controller $products_controller = null;
+
+ /**
+ * REST controller instance for variations.
+ *
+ * @var WC_REST_Product_Variations_Controller|null
+ */
+ private ?WC_REST_Product_Variations_Controller $variations_controller = null;
+
+ /**
+ * Cached REST request instance for products.
+ *
+ * @var WP_REST_Request<array<string, mixed>>|null
+ */
+ private ?WP_REST_Request $products_request = null;
+
+ /**
+ * Cached REST request instance for variations.
+ *
+ * @var WP_REST_Request<array<string, mixed>>|null
+ */
+ private ?WP_REST_Request $variations_request = null;
+
+ /**
+ * Initialize the mapper.
+ *
+ * @internal
+ * @return void
+ */
+ final public function init(): void {
+ $this->products_controller = new WC_REST_Products_Controller();
+ $this->variations_controller = new WC_REST_Product_Variations_Controller();
+ }
+
+ /**
+ * Set fields to include in the product mapping.
+ *
+ * @since 10.5.0
+ *
+ * @param string|null $fields Fields to include in the product mapping.
+ * @return void
+ */
+ public function set_fields( ?string $fields = null ): void {
+ $this->fields = $fields;
+ $this->products_request = null; // Invalidate the cached request.
+ }
+
+ /**
+ * Set fields to include in the variation mapping.
+ *
+ * @since 10.5.0
+ *
+ * @param string|null $fields Fields to include in the variation mapping.
+ * @return void
+ */
+ public function set_variation_fields( ?string $fields = null ): void {
+ $this->variation_fields = $fields;
+ $this->variations_request = null; // Invalidate the cached request.
+ }
+
+ /**
+ * Map WooCommerce product to catalog row
+ *
+ * @since 10.5.0
+ *
+ * @param WC_Product $product Product to map.
+ * @return array Mapped product data array.
+ * @throws \RuntimeException If the controller is not initialized.
+ */
+ public function map_product( WC_Product $product ): array {
+ $is_variation = $product->is_type( 'variation' );
+ $controller = $is_variation
+ ? $this->variations_controller
+ : $this->products_controller;
+
+ // This should never be the case, as the class should be loaded through DI.
+ if ( null === $controller ) {
+ throw new \RuntimeException( 'ProductMapper::init() must be called before map_product().' );
+ }
+
+ $request = $is_variation ? $this->get_variations_request() : $this->get_products_request();
+ $response = $controller->prepare_object_for_response( $product, $request );
+
+ // Apply _fields filtering (normally done by REST server dispatch).
+ $fields = $is_variation ? $this->variation_fields : $this->fields;
+ if ( null !== $fields ) {
+ $response = rest_filter_response_fields( $response, rest_get_server(), $request );
+ }
+
+ $row = array(
+ 'type' => $product->get_type(),
+ 'data' => $response->get_data(),
+ );
+
+ /**
+ * Filter mapped catalog product data.
+ *
+ * @since 10.5.0
+ * @param array $row Mapped product data.
+ * @param WC_Product $product Product object.
+ */
+ return apply_filters( 'woocommerce_pos_catalog_map_product', $row, $product );
+ }
+
+ /**
+ * Get the REST request instance for products.
+ *
+ * @return WP_REST_Request<array<string, mixed>>
+ */
+ protected function get_products_request(): WP_REST_Request {
+ if ( null === $this->products_request ) {
+ /**
+ * Type hint for PHPStan generics.
+ *
+ * @var WP_REST_Request<array<string, mixed>> $request
+ * */
+ $request = new WP_REST_Request( 'GET' );
+ $this->products_request = $request;
+ $this->products_request->set_param( 'context', 'view' );
+
+ if ( null !== $this->fields ) {
+ $this->products_request->set_param( '_fields', $this->fields );
+ }
+ }
+
+ return $this->products_request;
+ }
+
+ /**
+ * Get the REST request instance for variations.
+ *
+ * @return WP_REST_Request<array<string, mixed>>
+ */
+ protected function get_variations_request(): WP_REST_Request {
+ if ( null === $this->variations_request ) {
+ /**
+ * Type hint for PHPStan generics.
+ *
+ * @var WP_REST_Request<array<string, mixed>> $request
+ */
+ $request = new WP_REST_Request( 'GET' );
+ $this->variations_request = $request;
+ $this->variations_request->set_param( 'context', 'view' );
+
+ if ( null !== $this->variation_fields ) {
+ $this->variations_request->set_param( '_fields', $this->variation_fields );
+ }
+ }
+
+ return $this->variations_request;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/ProductFeed.php b/plugins/woocommerce/src/Internal/ProductFeed/ProductFeed.php
new file mode 100644
index 0000000000..8b7e8ee09a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/ProductFeed.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Plugin class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationRegistry;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSIntegration;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Main Product Feed class.
+ *
+ * @since 10.5.0
+ */
+final class ProductFeed implements RegisterHooksInterface {
+ /**
+ * Integration registry.
+ *
+ * @var IntegrationRegistry
+ */
+ private IntegrationRegistry $integration_registry;
+
+ /**
+ * Dependency injector.
+ *
+ * @param IntegrationRegistry $integration_registry The integration registry.
+ * @param POSIntegration $pos_integration The POS integration.
+ * @internal
+ */
+ public function init( // phpcs:ignore WooCommerce.Functions.InternalInjectionMethod.MissingFinal
+ IntegrationRegistry $integration_registry,
+ POSIntegration $pos_integration
+ ): void {
+ $this->integration_registry = $integration_registry;
+ $this->integration_registry->register_integration( $pos_integration );
+ }
+
+ /**
+ * Allows extensions to register integrations.
+ *
+ * @since 10.5.0
+ * @param IntegrationInterface $integration The integration to register.
+ * @return void
+ */
+ public function register_integration( IntegrationInterface $integration ): void {
+ $this->integration_registry->register_integration( $integration );
+ }
+
+ /**
+ * Initialize plugin components
+ *
+ * @since 10.5.0
+ */
+ public function register(): void {
+ // Let all integrations register their hooks.
+ foreach ( $this->integration_registry->get_integrations() as $integration ) {
+ $integration->register_hooks();
+ }
+ }
+
+ /**
+ * Plugin activation
+ *
+ * @since 10.5.0
+ */
+ public function activate(): void {
+ foreach ( $this->integration_registry->get_integrations() as $integration ) {
+ $integration->activate();
+ }
+ }
+
+ /**
+ * Plugin deactivation
+ *
+ * @since 10.5.0
+ */
+ public function deactivate(): void {
+ foreach ( $this->integration_registry->get_integrations() as $integration ) {
+ $integration->deactivate();
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
new file mode 100644
index 0000000000..0b418d09b2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
@@ -0,0 +1,289 @@
+<?php
+/**
+ * JSON File Feed class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Storage;
+
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Exception;
+
+// This file works directly with local files. That's fine.
+// phpcs:disable WordPress.WP.AlternativeFunctions
+
+/**
+ * File-backed JSON feed storage.
+ *
+ * This class writes JSON directly to a file, entry by entry, without keeping everything in memory.
+ *
+ * @since 10.5.0
+ */
+class JsonFileFeed implements FeedInterface {
+ public const UPLOAD_DIR = 'product-feeds';
+
+ /**
+ * Indicates if there are previous entries in the feed.
+ *
+ * @var bool
+ */
+ private $has_entries = false;
+
+ /**
+ * The base name of the feed file.
+ *
+ * @var string
+ */
+ private $base_name;
+
+ /**
+ * The name of the feed file, no directory.
+ *
+ * @var string
+ */
+ private $file_name;
+
+ /**
+ * The path to the feed file.
+ *
+ * @var string
+ */
+ private $file_path;
+
+ /**
+ * The file handle.
+ *
+ * @var resource|false|null
+ */
+ private $file_handle = null;
+
+ /**
+ * Indicates if the feed file has been completed.
+ *
+ * @var bool
+ */
+ private $file_completed = false;
+
+ /**
+ * The URL of the feed file.
+ *
+ * @var string|null
+ */
+ private $file_url = null;
+
+ /**
+ * Indicates if the feed file is in a temp directory.
+ *
+ * @var bool
+ */
+ private $is_temp_filepath = false;
+
+ /**
+ * Constructor.
+ *
+ * @param string $base_name The base name of the feed file.
+ */
+ public function __construct( string $base_name ) {
+ $this->base_name = $base_name;
+ }
+
+ /**
+ * Start the feed.
+ *
+ * @return void
+ * @throws Exception If the feed directory cannot be created.
+ */
+ public function start(): void {
+ /**
+ * 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 10.5.0
+ */
+ $current_time = apply_filters( 'woocommerce_product_feed_time', time(), $this );
+ $hash_data = $this->base_name . gmdate( 'r', $current_time );
+ $this->file_name = sprintf(
+ '%s-%s-%s.json',
+ $this->base_name,
+ gmdate( 'Y-m-d', $current_time ),
+ wp_hash( $hash_data )
+ );
+
+ // Start by trying to use a temp directory to generate the feed.
+ $this->file_path = get_temp_dir() . DIRECTORY_SEPARATOR . $this->file_name;
+ $this->file_handle = fopen( $this->file_path, 'w' );
+ if ( false === $this->file_handle ) {
+ // Fall back to immediately using the upload directory for generation.
+ $upload_dir = $this->get_upload_dir();
+ $this->file_path = $upload_dir['path'] . $this->file_name;
+ $this->file_handle = fopen( $this->file_path, 'w' );
+ } else {
+ $this->is_temp_filepath = true;
+ }
+
+ if ( false === $this->file_handle ) {
+ throw new Exception(
+ esc_html(
+ sprintf(
+ /* translators: %s: directory path */
+ __( 'Unable to open feed file for writing: %s', 'woocommerce' ),
+ $this->file_path
+ )
+ )
+ );
+ }
+
+ // Open the array.
+ fwrite( $this->file_handle, '[' );
+ }
+
+ /**
+ * Add an entry to the feed.
+ *
+ * @param array $entry The entry to add.
+ * @return void
+ */
+ public function add_entry( array $entry ): void {
+ if ( ! is_resource( $this->file_handle ) ) {
+ return;
+ }
+
+ if ( ! $this->has_entries ) {
+ $this->has_entries = true;
+ } else {
+ fwrite( $this->file_handle, ',' );
+ }
+
+ $json = wp_json_encode( $entry );
+ if ( false !== $json ) {
+ fwrite( $this->file_handle, $json );
+ }
+ }
+
+ /**
+ * End the feed.
+ *
+ * @return void
+ */
+ public function end(): void {
+ if ( ! is_resource( $this->file_handle ) ) {
+ return;
+ }
+
+ // Close the array and the file.
+ fwrite( $this->file_handle, ']' );
+ fclose( $this->file_handle );
+
+ // Indicate that we have a complete file.
+ $this->file_completed = true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get_file_path(): ?string {
+ if ( ! $this->file_completed ) {
+ return null;
+ }
+
+ return $this->file_path;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws Exception If the feed file cannot be moved to the upload directory.
+ */
+ public function get_file_url(): ?string {
+ if ( ! $this->file_completed ) {
+ return null;
+ }
+
+ $upload_dir = $this->get_upload_dir();
+
+ // Move the file to the upload directory if it is in temp.
+ if ( $this->is_temp_filepath ) {
+ $tmp_path = $this->file_path;
+ $this->file_path = $upload_dir['path'] . $this->file_name;
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ if ( ! @copy( $tmp_path, $this->file_path ) ) {
+ $error = error_get_last();
+ $error_message = is_array( $error ) ? $error['message'] : 'Unknown error';
+ throw new Exception(
+ esc_html(
+ sprintf(
+ /* translators: %1$s: file path, %2$s: error message */
+ __( 'Unable to move feed file %1$s to upload directory: %2$s', 'woocommerce' ),
+ $this->file_path,
+ $error_message
+ )
+ )
+ );
+ }
+
+ unlink( $tmp_path );
+
+ $this->is_temp_filepath = false;
+ }
+
+ // Generate the URL.
+ $this->file_url = $upload_dir['url'] . $this->file_name;
+
+ return $this->file_url;
+ }
+
+ /**
+ * Get the upload directory for the feed.
+ *
+ * @return array {
+ * The upload directory for the feed. Both fields end with the right trailing slash.
+ *
+ * @type string $path The path to the upload directory.
+ * @type string $url The URL to the upload directory.
+ * }
+ * @throws Exception If the upload directory cannot be created.
+ */
+ private function get_upload_dir(): array {
+ // Only generate everything once.
+ static $prepared;
+ if ( isset( $prepared ) ) {
+ return $prepared;
+ }
+
+ $upload_dir = wp_upload_dir( null, true );
+ $directory_path = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . self::UPLOAD_DIR . DIRECTORY_SEPARATOR;
+
+ // Try to create the directory if it does not exist.
+ if ( ! is_dir( $directory_path ) ) {
+ FilesystemUtil::mkdir_p_not_indexable( $directory_path );
+ }
+
+ // `mkdir_p_not_indexable()` returns `void`, we have to check again.
+ if ( ! is_dir( $directory_path ) ) {
+ throw new Exception(
+ esc_html(
+ sprintf(
+ /* translators: %s: directory path */
+ __( 'Unable to create feed directory: %s', 'woocommerce' ),
+ $directory_path
+ )
+ )
+ );
+ }
+
+ $directory_url = $upload_dir['baseurl'] . '/' . self::UPLOAD_DIR . '/';
+
+ // Follow the format, returned by `wp_upload_dir()`.
+ $prepared = array(
+ 'path' => $directory_path,
+ 'url' => $directory_url,
+ );
+ return $prepared;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Utils/MemoryManager.php b/plugins/woocommerce/src/Internal/ProductFeed/Utils/MemoryManager.php
new file mode 100644
index 0000000000..f311fb930d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Utils/MemoryManager.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Memory Manager class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Utils;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Helper class for managing memory.
+ *
+ * @since 10.5.0
+ */
+class MemoryManager {
+ /**
+ * Get available memory as a percentage of the total memory limit.
+ *
+ * @since 10.5.0
+ *
+ * @return int Available memory as a percentage of the total memory limit.
+ */
+ public function get_available_memory(): int {
+ $memory_limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
+ if ( 0 >= $memory_limit ) {
+ // Some systems have "unlimited" memory.
+ // We should treat that as if there is none left.
+ return 0;
+ }
+ return (int) round( 100 - ( memory_get_usage( true ) / $memory_limit ) * 100 );
+ }
+
+ /**
+ * Flush all caches.
+ *
+ * @since 10.5.0
+ */
+ public function flush_caches(): void {
+ global $wpdb, $wp_object_cache;
+
+ $wpdb->queries = array();
+
+ wp_cache_flush();
+
+ if ( ! is_object( $wp_object_cache ) ) {
+ return;
+ }
+
+ // These properties exist on various object cache implementations.
+ $wp_object_cache->group_ops = array(); // @phpstan-ignore property.notFound
+ $wp_object_cache->stats = array(); // @phpstan-ignore property.notFound
+ $wp_object_cache->memcache_debug = array(); // @phpstan-ignore property.notFound
+ $wp_object_cache->cache = array(); // @phpstan-ignore property.notFound
+
+ // This method is specific to certain memcached implementations.
+ if ( method_exists( $wp_object_cache, '__remoteset' ) ) {
+ $wp_object_cache->__remoteset(); // important.
+ }
+
+ $this->collect_garbage();
+ }
+
+ /**
+ * Collect garbage.
+ */
+ private function collect_garbage(): void {
+ static $gc_threshold = 5000;
+ static $gc_too_low_in_a_row = 0;
+ static $gc_too_high_in_a_row = 0;
+
+ $gc_threshold_step = 2_500;
+ $gc_status = gc_status();
+
+ if ( $gc_threshold > $gc_status['threshold'] ) {
+ // If PHP managed to collect memory in the meantime and established threshold lower than ours, just use theirs.
+ $gc_threshold = $gc_status['threshold'];
+ }
+
+ if ( $gc_status['roots'] > $gc_threshold ) {
+ $collected = gc_collect_cycles();
+ if ( $collected < 100 ) {
+ if ( $gc_too_low_in_a_row > 0 ) {
+ $gc_too_low_in_a_row = 0;
+ // Raise GC threshold if we collected too little twice in a row.
+ $gc_threshold += $gc_threshold_step;
+ $gc_threshold = min( $gc_threshold, 1_000_000_000, $gc_status['threshold'] );
+ } else {
+ ++$gc_too_low_in_a_row;
+ }
+ $gc_too_high_in_a_row = 0;
+ } else {
+ if ( $gc_too_high_in_a_row > 0 ) {
+ $gc_too_high_in_a_row = 0;
+ // Lower GC threshold if we collected more than enough twice in a row.
+ $gc_threshold -= $gc_threshold_step;
+ $gc_threshold = max( $gc_threshold, 5_000 );
+ } else {
+ ++$gc_too_high_in_a_row;
+ }
+ $gc_too_low_in_a_row = 0;
+ }
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Utils/StringHelper.php b/plugins/woocommerce/src/Internal/ProductFeed/Utils/StringHelper.php
new file mode 100644
index 0000000000..09859d007a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Utils/StringHelper.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * String Helper class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Utils;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * String utility helper functions
+ *
+ * @since 10.5.0
+ */
+class StringHelper {
+ /**
+ * Convert value to boolean string ('true' or 'false')
+ *
+ * @since 10.5.0
+ *
+ * @param mixed $value Value to convert.
+ * @return string 'true' or 'false'.
+ */
+ public static function bool_string( $value ): string {
+ if ( is_bool( $value ) ) {
+ return $value ? 'true' : 'false';
+ }
+ if ( is_scalar( $value ) || null === $value ) {
+ $value = strtolower( (string) $value );
+ } else {
+ $value = '';
+ }
+ return ( 'true' === $value || '1' === $value || 'yes' === $value ) ? 'true' : 'false';
+ }
+
+ /**
+ * Truncate text to specified length
+ *
+ * @since 10.5.0
+ *
+ * @param string $text Text to truncate.
+ * @param int $max_length Maximum length.
+ * @return string Truncated text.
+ */
+ public static function truncate( string $text, int $max_length ): string {
+ if ( mb_strlen( $text ) > $max_length ) {
+ return mb_substr( $text, 0, $max_length );
+ }
+ return $text;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php
new file mode 100644
index 0000000000..e41a235daf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php
@@ -0,0 +1,276 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Feed;
+
+use WC_Helper_Product;
+use WC_Product;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductLoader;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\WalkerProgress;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+
+/**
+ * ProductWalkerTest class.
+ */
+class ProductWalkerTest extends \WC_Unit_Test_Case {
+ /**
+ * Test container.
+ *
+ * @var TestContainer
+ */
+ private $test_container;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->test_container = wc_get_container();
+ }
+
+ /**
+ * Clean up test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ remove_all_filters( 'woocommerce_product_feed_args' );
+ $this->test_container->reset_all_replacements();
+ }
+
+ /**
+ * Data provider for walker tests.
+ *
+ * @return array Test scenarios.
+ */
+ public function provider_walker(): array {
+ return array(
+ 'No Results' => array(
+ 'number_of_products' => 0,
+ 'batch_size' => 10,
+ 'add_args_filter' => true,
+ ),
+ 'Single batch' => array(
+ 'number_of_products' => 10,
+ 'batch_size' => 100,
+ 'add_args_filter' => false,
+ ),
+ 'Multiple batches, half last batch' => array(
+ 'number_of_products' => 5 * 12 - 6,
+ 'batch_size' => 12,
+ 'add_args_filter' => true,
+ ),
+ 'Multiple batches, full last batch' => array(
+ 'number_of_products' => 5 * 13,
+ 'batch_size' => 13,
+ 'add_args_filter' => false,
+ ),
+ 'High number of batches, proper memory management' => array(
+ '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 = array();
+ $generated_products = 0;
+ for ( $i = 0; $i < $expected_iterations; $i++ ) {
+ $page = array();
+ for ( $j = 1; $j <= $batch_size && $generated_products++ < $number_of_products; $j++ ) {
+ $page[] = WC_Helper_Product::create_simple_product();
+ }
+
+ $loader_results[] = (object) array(
+ 'products' => $page,
+ 'total' => $number_of_products,
+ 'max_num_pages' => $expected_iterations,
+ );
+ }
+
+ // Additional parameters for the query.
+ $parent_exclude = -156;
+ $additional_query_args = array(
+ 'parent_exclude' => $parent_exclude,
+ 'category' => array( '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( ProductLoader::class, $mock_loader );
+
+ $mock_memory_manager = $this->createMock( MemoryManager::class );
+ $this->test_container->replace( 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( array( '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 array(
+ '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 array( 'error' => 'Some validation error' );
+ }
+ return array();
+ }
+ );
+
+ // 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(
+ 'woocommerce_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/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
new file mode 100644
index 0000000000..48e2ab1cc3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
@@ -0,0 +1,108 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ApiController;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\AsyncGenerator;
+use PHPUnit\Framework\MockObject\MockObject;
+use WP_REST_Request;
+
+/**
+ * API controller test class.
+ */
+class ApiControllerTest extends \WC_Unit_Test_Case {
+ /**
+ * System under test.
+ *
+ * @var ApiController
+ */
+ private ApiController $sut;
+
+ /**
+ * Mock async generator.
+ *
+ * @var MockObject|AsyncGenerator
+ */
+ private $mock_async_generator;
+
+ /**
+ * Test container.
+ *
+ * @var TestContainer
+ */
+ private $test_container;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ // Reset first to ensure fresh instances.
+ $this->test_container = wc_get_container();
+ $this->mock_async_generator = $this->createMock( AsyncGenerator::class );
+ $this->test_container->replace( AsyncGenerator::class, $this->mock_async_generator );
+
+ $this->sut = $this->test_container->get( ApiController::class );
+ }
+
+ /**
+ * Clean up test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ $this->test_container->reset_all_replacements();
+ }
+
+ /**
+ * Data provider for generate_feed tests.
+ *
+ * @return array Test scenarios.
+ */
+ public function provider_generate_feed(): array {
+ return array(
+ 'No force-generation check, no fields' => array( false, null ),
+ 'No force-generation check, with fields' => array( false, 'id,name' ),
+ 'Force generation, with fields' => array( true, 'id,name' ),
+ );
+ }
+
+ /**
+ * Test the generate_feed endpoint method.
+ *
+ * @dataProvider provider_generate_feed
+ * @param bool $force_regeneration Whether to force regeneration of the feed.
+ * @param string|null $fields The fields to include in the feed.
+ */
+ public function test_generate_feed( bool $force_regeneration, ?string $fields = null ) {
+ $request = new WP_REST_Request( 'POST', '/wc/pos/v1/catalog/create' );
+
+ if ( $force_regeneration ) {
+ $request->set_param( 'force', true );
+ }
+ if ( $fields ) {
+ $request->set_param( '_product_fields', $fields );
+ }
+
+ $this->mock_async_generator->expects( $this->once() )
+ ->method( $force_regeneration ? 'force_regeneration' : 'get_status' )
+ ->with( $fields ? array( '_product_fields' => $fields ) : array() )
+ ->willReturn(
+ array(
+ 'action_id' => 6789,
+ 'path' => '/tmp/random_path.json',
+ 'url' => 'https://example.com/feed.json',
+ )
+ );
+
+ $response = $this->sut->generate_feed( $request );
+ $response_data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayNotHasKey( 'action_id', $response_data );
+ $this->assertArrayNotHasKey( 'path', $response_data );
+ $this->assertArrayHasKey( 'url', $response_data );
+ $this->assertEquals( 'https://example.com/feed.json', $response_data['url'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
new file mode 100644
index 0000000000..cd7fd0de06
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
@@ -0,0 +1,106 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\AsyncGenerator;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSIntegration;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ProductMapper;
+use WC_Helper_Product;
+
+/**
+ * Async generator test class.
+ */
+class AsyncGeneratorTest extends \WC_Unit_Test_Case {
+ /**
+ * System under test.
+ *
+ * @var AsyncGenerator
+ */
+ private AsyncGenerator $sut;
+
+ /**
+ * Mock integration.
+ *
+ * @var MockObject|POSIntegration
+ */
+ private $mock_integration;
+
+ /**
+ * Test container.
+ *
+ * @var TestContainer
+ */
+ private $test_container;
+
+ // Option key for tests.
+ private const OPTION_KEY = 'product_feed_async_test';
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ // Reset first to ensure AsyncGenerator gets the mock, not a cached real instance.
+ $this->test_container = wc_get_container();
+
+ $this->mock_integration = $this->createMock( POSIntegration::class );
+ $this->test_container->replace( POSIntegration::class, $this->mock_integration );
+
+ $this->sut = $this->test_container->get( AsyncGenerator::class );
+ }
+
+ /**
+ * Clean up test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+
+ delete_option( self::OPTION_KEY );
+ $this->test_container->reset_all_replacements();
+ }
+
+ /**
+ * Test that feed generation action forwards arguments to mapper.
+ */
+ public function test_feed_generation_action_forwards_args() {
+ // Make sure at least one product is present. We will not check it here.
+ WC_Helper_Product::create_simple_product();
+
+ // Set the initial option to indicate scheduled state.
+ $status = array(
+ 'state' => AsyncGenerator::STATE_SCHEDULED,
+ 'args' => array(
+ '_product_fields' => 'id,name',
+ '_variation_fields' => 'id,name,url',
+ ),
+ );
+ update_option( self::OPTION_KEY, $status );
+
+ // Expect the mapper to be called with the fields.
+ $mock_mapper = $this->createMock( ProductMapper::class );
+ $mock_mapper->expects( $this->once() )
+ ->method( 'set_fields' )
+ ->with( 'id,name' );
+ $mock_mapper->expects( $this->once() )
+ ->method( 'set_variation_fields' )
+ ->with( 'id,name,url' );
+ $mock_mapper->expects( $this->atLeast( 1 ) )
+ ->method( 'map_product' )
+ ->willReturn( array() );
+
+ // Replace the mapper with the integration.
+ $this->mock_integration->expects( $this->atLeast( 1 ) )
+ ->method( 'get_product_mapper' )
+ ->willReturn( $mock_mapper );
+
+ // Trigger the action.
+ $this->sut->feed_generation_action( self::OPTION_KEY );
+
+ // Check the final status.
+ $updated_status = get_option( self::OPTION_KEY );
+ $this->assertEquals( AsyncGenerator::STATE_COMPLETED, $updated_status['state'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapperTest.php
new file mode 100644
index 0000000000..eef0855104
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapperTest.php
@@ -0,0 +1,84 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ProductMapper;
+use WC_Helper_Product;
+
+/**
+ * Product mapper test class.
+ */
+class ProductMapperTest extends \WC_Unit_Test_Case {
+ /**
+ * System under test.
+ *
+ * @var ProductMapper
+ */
+ private ProductMapper $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new ProductMapper();
+ $this->sut->init(); // Could be done through DI, but there are no parameter dependencies.
+ }
+
+ /**
+ * Clean up test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+
+ $this->sut->set_fields( null );
+ }
+
+ /**
+ * Test mapping a simple product.
+ */
+ public function test_map_product_simple_product(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Test Product' );
+ $product->set_description( 'Test Description' );
+ $product->set_regular_price( '99.99' );
+
+ $result = $this->sut->map_product( $product );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'type', $result );
+ $this->assertArrayHasKey( 'data', $result );
+ $this->assertArrayHasKey( 'id', $result['data'] );
+ $this->assertArrayHasKey( 'name', $result['data'] );
+ $this->assertArrayHasKey( 'description', $result['data'] );
+ $this->assertArrayHasKey( 'price', $result['data'] );
+ $this->assertArrayHasKey( 'downloadable', $result['data'] );
+ $this->assertArrayHasKey( 'parent_id', $result['data'] );
+ $this->assertArrayHasKey( 'images', $result['data'] );
+ }
+
+ /**
+ * Test mapping a product with specific fields.
+ */
+ public function test_map_product_with_fields(): void {
+ $this->sut->set_fields( 'id,name,description' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( 'Test Product' );
+ $product->set_description( 'Test Description' );
+ $product->set_regular_price( '99.99' );
+
+ $result = $this->sut->map_product( $product );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'type', $result );
+ $this->assertArrayHasKey( 'data', $result );
+ $this->assertArrayHasKey( 'name', $result['data'] );
+ $this->assertArrayHasKey( 'description', $result['data'] );
+ $this->assertArrayNotHasKey( 'price', $result['data'] );
+ $this->assertArrayNotHasKey( 'downloadable', $result['data'] );
+ $this->assertArrayNotHasKey( 'parent_id', $result['data'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
new file mode 100644
index 0000000000..21947be02c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
@@ -0,0 +1,157 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Storage;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Storage\JsonFileFeed;
+
+// This file works directly with local files. That's fine.
+// phpcs:disable WordPress.WP.AlternativeFunctions
+
+if ( ! function_exists( 'WP_Filesystem' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+}
+
+
+/**
+ * JsonFileFeedTest class.
+ */
+class JsonFileFeedTest extends \WC_Unit_Test_Case {
+ /**
+ * Clean up test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ $this->get_and_delete_dir();
+ remove_all_filters( 'woocommerce_product_feed_time' );
+ }
+
+ /**
+ * Test that feed file is created correctly.
+ */
+ public function test_feed_file_is_created() {
+ // Use the current time for the test as the time in the SUT to avoid flakiness.
+ $current_time = time();
+ add_filter( 'woocommerce_product_feed_time', fn() => $current_time );
+
+ $feed = new JsonFileFeed( 'test-feed' );
+ $feed->start();
+ $feed->end();
+
+ // The file should be in `/tmp` at first.
+ $path = $feed->get_file_path();
+ $this->assertStringStartsWith( get_temp_dir(), $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 ) );
+
+ // Once a URL is retrieved, the file will be moved to the uploads dir.
+ $url = $feed->get_file_url();
+ $path2 = $feed->get_file_path();
+ $this->assertNotNull( $url );
+ $this->assertStringContainsString( 'uploads/product-feeds', $path2 );
+ $this->assertStringEndsWith( '.json', (string) $url );
+ $this->assertStringContainsString( '/product-feeds/', (string) $url );
+ }
+
+ /**
+ * Test that feed file is created with entries.
+ */
+ public function test_feed_file_is_created_with_entries() {
+ $data = array(
+ array(
+ 'name' => 'First Entry',
+ 'price' => 100,
+ ),
+ array(
+ '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() )
+ );
+ }
+
+ /**
+ * Test that get_file_url returns null if feed is not completed.
+ */
+ 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();
+ }
+
+ /**
+ * Test that add_entry before start is a no-op (does not throw).
+ */
+ public function test_add_entry_before_start_is_noop() {
+ $feed = new JsonFileFeed( 'test-feed' );
+ // Should not throw - silently returns when file handle is not ready.
+ $feed->add_entry( array( 'name' => 'oops' ) );
+ $this->assertNull( $feed->get_file_path() );
+ }
+
+ /**
+ * Test that end before start is a no-op (does not throw).
+ */
+ public function test_end_before_start_is_noop() {
+ $feed = new JsonFileFeed( 'test-feed' );
+ // Should not throw - silently returns when file handle is not ready.
+ $feed->end();
+ $this->assertNull( $feed->get_file_path() );
+ }
+
+ /**
+ * Test that get_file_url throws when directory cannot be created.
+ */
+ public function test_get_file_url_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' );
+
+ $this->expectException( \Exception::class );
+
+ try {
+ $feed = new JsonFileFeed( 'test-feed' );
+ $feed->start();
+ $feed->end();
+ $feed->get_file_url();
+ } 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/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Utils/StringHelperTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Utils/StringHelperTest.php
new file mode 100644
index 0000000000..d7d48e16bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Utils/StringHelperTest.php
@@ -0,0 +1,25 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Utils;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\StringHelper;
+
+/**
+ * StringHelper test class.
+ */
+class StringHelperTest extends \WC_Unit_Test_Case {
+ /**
+ * Simple assertion.
+ */
+ public function test_demo() {
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Test a static method to make sure the autoloader works.
+ */
+ public function test_plugin_class() {
+ $this->assertEquals( 'true', StringHelper::bool_string( 'yes' ) );
+ }
+}