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).
 	 */