Commit 32d1511ea2c for woocommerce
commit 32d1511ea2c58bfbbeb10906f155cfd4db2e7d0d
Author: Josh Heald <joshheald@users.noreply.github.com>
Date: Thu Aug 20 08:46:52 2026 +0100
Fix POS catalog download URLs after site URL changes (#67815)
* Fix POS catalog download URL generation
* Add changelog entry for POS catalog download URL fix
* Centralize product feed URL construction
diff --git a/plugins/woocommerce/changelog/fix-pos-catalog-response-url b/plugins/woocommerce/changelog/fix-pos-catalog-response-url
new file mode 100644
index 00000000000..eab8999f3af
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-pos-catalog-response-url
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure POS catalog download URLs reflect the current site URL.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
index 71074f03b21..33f5b303766 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
use Automattic\WooCommerce\Container;
+use Automattic\WooCommerce\Internal\ProductFeed\Storage\JsonFileFeed;
use WP_REST_Request;
use WP_REST_Response;
@@ -106,6 +107,19 @@ class ApiController {
? $generator->force_regeneration( $params )
: $generator->get_status( $params );
+ // Build the download URL from the current site configuration instead of persisting it with the feed status.
+ unset( $response['url'] );
+ if ( AsyncGenerator::STATE_COMPLETED === ( $response['state'] ?? '' ) ) {
+ $file_name = ! empty( $response['file_name'] )
+ ? (string) $response['file_name']
+ : wp_basename( (string) ( $response['path'] ?? '' ) );
+
+ $file_url = JsonFileFeed::get_file_url_for_identifier( $file_name );
+ if ( null !== $file_url ) {
+ $response['url'] = $file_url;
+ }
+ }
+
// Use the right datetime format.
if ( isset( $response['scheduled_at'] ) ) {
$response['scheduled_at'] = wc_rest_prepare_date_response( $response['scheduled_at'] );
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
index 8c631883fe4..5abb1e59966 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
@@ -269,7 +269,6 @@ class AsyncGenerator {
$status['state'] = self::STATE_COMPLETED;
$status['progress'] = 100;
- $status['url'] = $feed->get_file_url();
$status['path'] = $feed->get_file_path();
$status['completed_at'] = time();
update_option( $option_key, $status );
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
index 9c1a779d3f9..a580fcf5ea4 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
@@ -128,7 +128,7 @@ class JsonFileFeed implements ResumableFeedInterface {
$this->file_url = null;
if ( null !== $resume_identifier ) {
- if ( ! $this->is_valid_feed_identifier( $resume_identifier ) ) {
+ if ( ! self::is_valid_feed_identifier( $resume_identifier ) ) {
throw new Exception(
esc_html(
sprintf(
@@ -258,7 +258,7 @@ class JsonFileFeed implements ResumableFeedInterface {
*/
public function delete( string $identifier ): void {
// Never turn an identifier that is actually a path into a delete outside the feed directory.
- if ( ! $this->is_valid_feed_identifier( $identifier ) ) {
+ if ( ! self::is_valid_feed_identifier( $identifier ) ) {
return;
}
@@ -278,7 +278,7 @@ class JsonFileFeed implements ResumableFeedInterface {
* @param string $identifier The feed file identifier to check.
* @return bool True if the identifier is a safe, plain `.json` file name.
*/
- private function is_valid_feed_identifier( string $identifier ): bool {
+ private static function is_valid_feed_identifier( string $identifier ): bool {
return '' !== $identifier
&& wp_basename( $identifier ) === $identifier
&& 'json' === strtolower( (string) pathinfo( $identifier, PATHINFO_EXTENSION ) );
@@ -413,11 +413,50 @@ class JsonFileFeed implements ResumableFeedInterface {
// Resolve the upload directory (also refreshes its .htaccess for file access) and build the URL.
$upload_dir = $this->get_upload_dir();
- $this->file_url = $upload_dir['url'] . $this->file_name;
+ $this->file_url = self::build_file_url( $upload_dir['url'], $this->file_name );
return $this->file_url;
}
+ /**
+ * Builds the current URL for a feed identifier.
+ *
+ * @param string $identifier The feed file identifier.
+ * @return string|null The feed URL, or null if the identifier is invalid.
+ *
+ * @since 11.2.0
+ */
+ public static function get_file_url_for_identifier( string $identifier ): ?string {
+ if ( ! self::is_valid_feed_identifier( $identifier ) ) {
+ return null;
+ }
+
+ $upload_dir = wp_upload_dir( null, false, true );
+ $directory_url = self::build_directory_url( untrailingslashit( $upload_dir['baseurl'] ) );
+ return self::build_file_url( $directory_url, rawurlencode( $identifier ) );
+ }
+
+ /**
+ * Builds the feed directory URL from an uploads base URL.
+ *
+ * @param string $uploads_base_url The uploads base URL.
+ * @return string The feed directory URL with a trailing slash.
+ */
+ private static function build_directory_url( string $uploads_base_url ): string {
+ return $uploads_base_url . '/' . self::UPLOAD_DIR . '/';
+ }
+
+ /**
+ * Appends a feed identifier to its directory URL.
+ *
+ * @param string $directory_url The feed directory URL with a trailing slash.
+ * @param string $identifier The feed file identifier.
+ * @return string The feed file URL.
+ */
+ private static function build_file_url( string $directory_url, string $identifier ): string {
+ return $directory_url . $identifier;
+ }
+
/**
* Get the upload directory for the feed.
*
@@ -461,7 +500,7 @@ class JsonFileFeed implements ResumableFeedInterface {
);
}
- $directory_url = $upload_dir['baseurl'] . '/' . self::UPLOAD_DIR . '/';
+ $directory_url = self::build_directory_url( $upload_dir['baseurl'] );
// Follow the format, returned by `wp_upload_dir()`.
$this->prepared_upload_dir = array(
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
index 44696a6f707..dad28754df5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
@@ -69,13 +69,13 @@ class ApiControllerTest extends \WC_Unit_Test_Case {
}
/**
- * Test the generate_feed endpoint method.
+ * @testdox Should prepare the feed status using the current uploads URL.
*
* @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 ) {
+ public function test_generate_feed( bool $force_regeneration, ?string $fields = null ): void {
$request = new WP_REST_Request( 'POST', '/wc/pos/v1/catalog/create' );
if ( $force_regeneration ) {
@@ -90,17 +90,29 @@ class ApiControllerTest extends \WC_Unit_Test_Case {
->with( $fields ? array( '_product_fields' => $fields ) : array() )
->willReturn(
array(
+ 'state' => AsyncGenerator::STATE_COMPLETED,
'action_id' => 6789,
'path' => '/tmp/random_path.json',
'file_name' => 'pos-catalog-feed.json',
'page' => 3,
'entries_written' => 250,
'updated_at' => time(),
- 'url' => 'https://example.com/feed.json',
+ 'url' => 'https://old.example/uploads/product-feeds/pos-catalog-feed.json',
)
);
- $response = $this->sut->generate_feed( $request );
+ $upload_dir_filter = function ( array $upload_dir ): array {
+ $upload_dir['baseurl'] = 'https://current.example/uploads';
+ return $upload_dir;
+ };
+ add_filter( 'upload_dir', $upload_dir_filter );
+
+ try {
+ $response = $this->sut->generate_feed( $request );
+ } finally {
+ remove_filter( 'upload_dir', $upload_dir_filter );
+ }
+
$response_data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
@@ -111,6 +123,42 @@ class ApiControllerTest extends \WC_Unit_Test_Case {
}
$this->assertArrayHasKey( 'url', $response_data );
- $this->assertEquals( 'https://example.com/feed.json', $response_data['url'] );
+ $this->assertEquals( 'https://current.example/uploads/product-feeds/pos-catalog-feed.json', $response_data['url'] );
+ }
+
+ /**
+ * @testdox Should replace a legacy serialized download URL without duplicating it.
+ */
+ public function test_generate_feed_replaces_legacy_serialized_url(): void {
+ $request = new WP_REST_Request( 'POST', '/wc/pos/v1/catalog/create' );
+
+ $this->mock_async_generator->expects( $this->once() )
+ ->method( 'get_status' )
+ ->with( array() )
+ ->willReturn(
+ array(
+ 'state' => AsyncGenerator::STATE_COMPLETED,
+ 'path' => '/tmp/pos-catalog-feed.json',
+ 'url' => 'https://old.example/uploads/product-feeds/pos-catalog-feed.json',
+ )
+ );
+
+ $upload_dir_filter = function ( array $upload_dir ): array {
+ $upload_dir['baseurl'] = 'https://current.example/uploads';
+ return $upload_dir;
+ };
+ add_filter( 'upload_dir', $upload_dir_filter );
+
+ try {
+ $response = $this->sut->generate_feed( $request );
+ } finally {
+ remove_filter( 'upload_dir', $upload_dir_filter );
+ }
+
+ $this->assertSame(
+ 'https://current.example/uploads/product-feeds/pos-catalog-feed.json',
+ $response->get_data()['url'],
+ 'A legacy absolute URL should be replaced with one URL based on the current site configuration.'
+ );
}
}
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
index ff873f0f6bd..f223ad1d7b8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
@@ -75,9 +75,9 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
}
/**
- * Test that feed generation action forwards arguments to mapper.
+ * @testdox Should forward arguments to the mapper without persisting a download URL.
*/
- public function test_feed_generation_action_forwards_args() {
+ public function test_feed_generation_action_forwards_args(): void {
// Make sure at least one product is present. We will not check it here.
WC_Helper_Product::create_simple_product();
@@ -119,6 +119,7 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
// Check the final status.
$updated_status = get_option( self::OPTION_KEY );
$this->assertEquals( AsyncGenerator::STATE_COMPLETED, $updated_status['state'] );
+ $this->assertArrayNotHasKey( 'url', $updated_status );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
index 82adf96c0dd..74d942aab7b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
@@ -302,6 +302,68 @@ class JsonFileFeedTest extends \WC_Unit_Test_Case {
$feed->end();
}
+ /**
+ * @testdox Should build an encoded feed URL using the current uploads URL.
+ */
+ public function test_get_file_url_for_identifier_uses_current_uploads_url(): void {
+ $upload_dir_filter = function ( array $upload_dir ): array {
+ $upload_dir['baseurl'] = 'https://current.example/uploads';
+ return $upload_dir;
+ };
+ add_filter( 'upload_dir', $upload_dir_filter );
+
+ try {
+ $this->assertSame(
+ 'https://current.example/uploads/product-feeds/feed%20name.json',
+ JsonFileFeed::get_file_url_for_identifier( 'feed name.json' )
+ );
+ } finally {
+ remove_filter( 'upload_dir', $upload_dir_filter );
+ }
+ }
+
+ /**
+ * @testdox Should not build a feed URL for an invalid identifier.
+ */
+ public function test_get_file_url_for_identifier_rejects_invalid_identifier(): void {
+ $this->assertNull( JsonFileFeed::get_file_url_for_identifier( '../feed.json' ) );
+ $this->assertNull( JsonFileFeed::get_file_url_for_identifier( 'feed.csv' ) );
+ }
+
+ /**
+ * @testdox Should preserve the upload URL resolved when an existing feed was opened.
+ */
+ public function test_get_file_url_preserves_cached_upload_url(): void {
+ $generation_upload_dir_filter = function ( array $upload_dir ): array {
+ $upload_dir['baseurl'] = 'https://generation.example/uploads';
+ return $upload_dir;
+ };
+ add_filter( 'upload_dir', $generation_upload_dir_filter );
+
+ try {
+ $feed = new JsonFileFeed( 'test-feed' );
+ $feed->start();
+ $feed->end();
+ } finally {
+ remove_filter( 'upload_dir', $generation_upload_dir_filter );
+ }
+
+ $response_upload_dir_filter = function ( array $upload_dir ): array {
+ $upload_dir['baseurl'] = 'https://response.example/uploads';
+ return $upload_dir;
+ };
+ add_filter( 'upload_dir', $response_upload_dir_filter );
+
+ try {
+ $this->assertStringStartsWith(
+ 'https://generation.example/uploads/product-feeds/',
+ (string) $feed->get_file_url()
+ );
+ } finally {
+ remove_filter( 'upload_dir', $response_upload_dir_filter );
+ }
+ }
+
/**
* Test that add_entry before start is a no-op (does not throw).
*/