Commit 06b04e7a277 for woocommerce
commit 06b04e7a277e9678d59dd5ef2af93c43443f0d15
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Mon Jul 6 14:21:45 2026 +0300
Add Webflow products migrator (CLI) (#64866)
* feature: register Webflow platform in migrator
* feature: add Webflow v2 API client
* feature: add Webflow products fetcher
* feature: add Webflow product mapper
* test: add Webflow migrator tests
* feature: import product/variation dimensions in migrator
* feature: map Webflow SKU dimensions
* docs: add Webflow migrator products assessment
* Cleanup
* fix: satisfy phpcs and phpstan on Webflow migrator files
* fix: address PR review feedback for Webflow migrator
* Add changelog entry for Webflow migrator
* Fix alignment warnings in WebflowMapperTest
* Fix remaining alignment warning in WebflowMapperTest
* fix: derive Webflow price decimals from currency minor units
price_to_decimal() hardcoded /100, silently 100x-inflating zero-decimal
currencies (JPY, KRW) and mis-scaling three-decimal ones (KWD, BHD).
Derive the divisor from the currency's num_decimals via core's
i18n/locale-info.php, defaulting to 2 for unknown codes.
* fix: preserve weight/dimensions when mapper omits the keys
The shared importer set weight/length/width/height unconditionally, so a
re-run cleared merchant data for platforms that do not map a field (Shopify
emits no length/width/height and may emit a null weight). Gate each setter on
array_key_exists so absent keys leave existing values untouched, while an
explicitly present empty value still clears. Adds update-path regression tests.
* docs: note single-SKU-with-properties is intentionally simple
* docs: note status/visibility intentionally mirror source on re-run
* feat: debug-log Webflow variations that resolve to zero attributes
* docs: note offset-pagination skip/double-import trade-off
* refactor: drop unused get_category_cache method
The mapper reads the _resolved_categories decoration, so this public getter
had no callers and a misleading 'Used by the mapper' docblock.
* test: cover WebflowClient 429 retry and budget-exhaustion paths
Adds a 429-then-200 retry test and a repeated-429 exhaustion test, exercising
the retry-after parsing and the WP_Error surfaced when retries run out. A
namespaced sleep() shadow keeps the backoff tests instant without touching
production code.
* test: cover non-USD currency decimals and variable-vs-simple boundary
Pins the per-currency price conversion (JPY zero-decimal, KWD three-decimal)
and the count($skus) > 1 guard that classifies single-SKU and no-property
products as simple.
* test: pin Webflow weight conversion to the store unit
Replaces the loose assertIsFloat/assertGreaterThan check with the converted
value (0.5 lb to kg), so the unit conversion is actually exercised.
* test: assert category resolution is cached and paginates fully
Adds a cross-batch call-count test (category collection + items resolved once,
not per batch) and a multi-page category fixture proving the items loop fetches
every page.
* fix: surface retry-budget-exhausted error on repeated 429
The retry loop always returned via process_response() on the final attempt, so
the budget-exhausted WP_Error was unreachable and exhaustion instead surfaced the
raw 429 status error. Restructure so a non-429 returns immediately and a 429 on
the last attempt falls through to the explicit retry-budget error.
* fix: use wc_get_weight-compatible unit so lb weights convert
normalize_weight_unit() emitted 'lb', which wc_get_weight() does not recognize
(it expects 'lbs'), so pound-to-kg conversions silently returned the source
value. Emit 'lbs' and drop the backwards lbs->lb rewrite of the store unit.
Caught by the now-pinned weight conversion test.
diff --git a/plugins/woocommerce/changelog/add-webflow-products-migrator b/plugins/woocommerce/changelog/add-webflow-products-migrator
new file mode 100644
index 00000000000..c11e8710059
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-webflow-products-migrator
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Webflow platform to the CLI products migrator.
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
index bfff8ff7b89..f5f1582b85a 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
@@ -342,7 +342,7 @@ class WooCommerceProductImporter {
$existing_posts = get_posts(
array(
'post_type' => 'product',
- 'post_status' => 'any', // Find regardless of status.
+ 'post_status' => 'any',
'meta_key' => '_original_product_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $product_data['original_product_id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'fields' => 'ids',
@@ -488,8 +488,20 @@ class WooCommerceProductImporter {
$product->set_date_created( $product_data['date_created_gmt'] );
}
- if ( ! empty( $product_data['weight'] ) ) {
- $product->set_weight( $product_data['weight'] );
+ // Only touch weight/dimensions when the mapper actually emits the key. This importer is
+ // shared across platforms (e.g. Shopify maps no length/width/height), so on a re-run an
+ // absent key must leave the existing value untouched rather than clearing it.
+ if ( array_key_exists( 'weight', $product_data ) ) {
+ $product->set_weight( $product_data['weight'] ?? '' );
+ }
+ if ( array_key_exists( 'length', $product_data ) ) {
+ $product->set_length( ! empty( $product_data['length'] ) ? (string) $product_data['length'] : '' );
+ }
+ if ( array_key_exists( 'width', $product_data ) ) {
+ $product->set_width( ! empty( $product_data['width'] ) ? (string) $product_data['width'] : '' );
+ }
+ if ( array_key_exists( 'height', $product_data ) ) {
+ $product->set_height( ! empty( $product_data['height'] ) ? (string) $product_data['height'] : '' );
}
if ( ! empty( $product_data['tax_status'] ) ) {
@@ -807,7 +819,20 @@ class WooCommerceProductImporter {
$variation->set_stock_quantity( $var_data['stock_quantity'] ?? null );
$variation->set_stock_status( $var_data['stock_status'] ?? 'instock' );
- $variation->set_weight( $var_data['weight'] ?? '' );
+ // Only touch weight/dimensions when the mapper emits the key (see the product block above):
+ // an absent key on a re-run must preserve the existing value rather than clear it.
+ if ( array_key_exists( 'weight', $var_data ) ) {
+ $variation->set_weight( $var_data['weight'] ?? '' );
+ }
+ if ( array_key_exists( 'length', $var_data ) ) {
+ $variation->set_length( ! empty( $var_data['length'] ) ? (string) $var_data['length'] : '' );
+ }
+ if ( array_key_exists( 'width', $var_data ) ) {
+ $variation->set_width( ! empty( $var_data['width'] ) ? (string) $var_data['width'] : '' );
+ }
+ if ( array_key_exists( 'height', $var_data ) ) {
+ $variation->set_height( ! empty( $var_data['height'] ) ? (string) $var_data['height'] : '' );
+ }
if ( ! empty( $var_data['tax_status'] ) ) {
$variation->set_tax_status( $var_data['tax_status'] );
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowClient.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowClient.php
new file mode 100644
index 00000000000..7f3b7f61229
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowClient.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * Webflow Client
+ *
+ * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Handles communication with the Webflow v2 REST API.
+ *
+ * @internal This class is part of the CLI Migrator feature and should not be used directly.
+ */
+class WebflowClient {
+
+ /**
+ * Base URL for the Webflow v2 API.
+ *
+ * @var string
+ */
+ private const API_BASE = 'https://api.webflow.com/v2';
+
+ /**
+ * How many times to retry on 429 responses before bailing.
+ *
+ * @var int
+ */
+ private const MAX_RETRIES = 3;
+
+ /**
+ * Platform credentials.
+ *
+ * @var array
+ */
+ private array $credentials;
+
+ /**
+ * Constructor.
+ *
+ * @param array $credentials Platform credentials array, expects 'site_id' and 'access_token'.
+ */
+ public function __construct( array $credentials ) {
+ $this->credentials = $credentials;
+ }
+
+ /**
+ * Makes a GET request to the Webflow REST API.
+ *
+ * @param string $path The API path relative to API base (e.g., '/sites/{id}/products').
+ * @param array $query_params Optional query parameters.
+ * @return object|array|\WP_Error Decoded JSON response (object or array) or WP_Error on failure.
+ */
+ public function rest_request( string $path, array $query_params = array() ) {
+ $credentials = $this->get_credentials();
+ if ( is_wp_error( $credentials ) ) {
+ return $credentials;
+ }
+
+ $endpoint = self::API_BASE . $path;
+ if ( ! empty( $query_params ) ) {
+ $endpoint = add_query_arg( $query_params, $endpoint );
+ }
+
+ $request_args = array(
+ 'method' => 'GET',
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $credentials['access_token'],
+ 'Accept' => 'application/json',
+ ),
+ 'timeout' => 60,
+ );
+
+ for ( $attempt = 0; $attempt <= self::MAX_RETRIES; $attempt++ ) {
+ $response = wp_remote_request( $endpoint, $request_args );
+ $response_code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
+
+ if ( 429 !== $response_code ) {
+ return $this->process_response( $response, $path );
+ }
+
+ // Rate limited: back off and retry until the budget is exhausted. The final
+ // attempt falls through to the retry-budget error below rather than retrying.
+ if ( $attempt < self::MAX_RETRIES ) {
+ $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
+ $delay = $retry_after > 0 ? $retry_after : ( 2 ** $attempt );
+ $delay = min( 30, max( 1, $delay ) );
+ if ( function_exists( 'sleep' ) ) {
+ sleep( $delay );
+ }
+ }
+ }
+
+ return new \WP_Error( 'api_error', "REST request to {$path} exceeded retry budget after repeated 429 responses." );
+ }
+
+ /**
+ * Return the configured site ID, or a WP_Error if not set.
+ *
+ * @return string|\WP_Error
+ */
+ public function get_site_id() {
+ if ( empty( $this->credentials['site_id'] ) ) {
+ return new \WP_Error(
+ 'api_error',
+ 'Webflow site_id is not configured. Please run: wp wc migrate setup --platform=webflow'
+ );
+ }
+
+ return (string) $this->credentials['site_id'];
+ }
+
+ /**
+ * Validate that credentials are present.
+ *
+ * @return array|\WP_Error Array with 'site_id' and 'access_token' keys, or WP_Error on failure.
+ */
+ private function get_credentials() {
+ if ( empty( $this->credentials['site_id'] ) || empty( $this->credentials['access_token'] ) ) {
+ return new \WP_Error(
+ 'api_error',
+ 'Webflow API credentials (site_id, access_token) are not configured. Please run: wp wc migrate setup --platform=webflow'
+ );
+ }
+
+ return array(
+ 'site_id' => (string) $this->credentials['site_id'],
+ 'access_token' => (string) $this->credentials['access_token'],
+ );
+ }
+
+ /**
+ * Process the API response.
+ *
+ * @param array|\WP_Error $response The HTTP response.
+ * @param string $path The API path (for error context).
+ * @return object|array|\WP_Error Decoded response or WP_Error.
+ */
+ private function process_response( $response, string $path ) {
+ if ( is_wp_error( $response ) ) {
+ return new \WP_Error( 'api_error', 'REST request failed: ' . $response->get_error_message() );
+ }
+
+ $response_code = (int) wp_remote_retrieve_response_code( $response );
+ $response_body = wp_remote_retrieve_body( $response );
+
+ if ( $response_code >= 300 ) {
+ $decoded = json_decode( $response_body );
+ $error_message = is_object( $decoded ) && isset( $decoded->message ) ? $decoded->message : $response_body;
+ return new \WP_Error(
+ 'api_error',
+ "REST request to {$path} failed with status code {$response_code}: " . $error_message
+ );
+ }
+
+ $data = json_decode( $response_body );
+
+ if ( JSON_ERROR_NONE !== json_last_error() ) {
+ return new \WP_Error( 'api_error', 'Failed to decode REST JSON response: ' . json_last_error_msg() );
+ }
+
+ return $data;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowFetcher.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowFetcher.php
new file mode 100644
index 00000000000..b228e934ead
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowFetcher.php
@@ -0,0 +1,364 @@
+<?php
+/**
+ * Webflow Fetcher
+ *
+ * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow;
+
+use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformFetcherInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Fetches products (and ecommerce category metadata) from the Webflow v2 API.
+ *
+ * The Webflow API exposes offset/limit pagination rather than cursors, but the
+ * migrator's PlatformFetcherInterface speaks cursors. We use the next offset as
+ * a stringified cursor and translate it back to an `offset` query parameter.
+ *
+ * Webflow does not expose a `/products/categories` endpoint. Categories live in
+ * an auto-provisioned CMS collection (slug `category`). We resolve them once
+ * via `/sites/{id}/collections` + `/collections/{id}/items` and stash a map of
+ * `cms_item_id => { name, slug }` and decorates each fetched product item with a
+ * `_resolved_categories` property so the mapper can read categories inline.
+ *
+ * @internal This class is part of the CLI Migrator feature and should not be used directly.
+ */
+class WebflowFetcher implements PlatformFetcherInterface {
+
+ /**
+ * Maximum page size allowed by Webflow.
+ *
+ * @var int
+ */
+ private const MAX_PAGE_SIZE = 100;
+
+ /**
+ * The Webflow client instance.
+ *
+ * @var WebflowClient
+ */
+ private WebflowClient $webflow_client;
+
+ /**
+ * Cached category map: cms_item_id => array{name:string, slug:string}.
+ *
+ * Null until resolved; empty array if the site has no categories collection.
+ *
+ * @var array<string,array{name:string,slug:string}>|null
+ */
+ private ?array $category_cache = null;
+
+ /**
+ * Constructor.
+ *
+ * @param array $credentials Platform credentials array.
+ */
+ public function __construct( array $credentials ) {
+ $this->webflow_client = new WebflowClient( $credentials );
+ }
+
+ /**
+ * Fetches a batch of products from the Webflow REST API.
+ *
+ * @param array $args Arguments for fetching. Supported keys:
+ * - 'limit': Max number of items per batch (default: 50, capped at 100).
+ * - 'after_cursor': Stringified next-offset for pagination (optional).
+ *
+ * @return array{items: array, cursor: ?string, has_next_page: bool}
+ */
+ public function fetch_batch( array $args ): array {
+ $site_id = $this->webflow_client->get_site_id();
+ if ( is_wp_error( $site_id ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::warning( 'Failed to fetch Webflow products: ' . $site_id->get_error_message() );
+ return $this->empty_batch();
+ }
+
+ $limit = (int) ( $args['limit'] ?? 50 );
+ $limit = max( 1, min( $limit, self::MAX_PAGE_SIZE ) );
+ $offset = 0;
+ if ( isset( $args['after_cursor'] ) && is_numeric( $args['after_cursor'] ) ) {
+ $offset = max( 0, (int) $args['after_cursor'] );
+ }
+
+ $query = array(
+ 'limit' => $limit,
+ 'offset' => $offset,
+ );
+
+ $response = $this->webflow_client->rest_request( "/sites/{$site_id}/products", $query );
+
+ if ( is_wp_error( $response ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::warning( 'Failed to fetch products from Webflow: ' . $response->get_error_message() );
+ return $this->empty_batch();
+ }
+
+ if ( ! is_object( $response ) || ! isset( $response->items ) || ! is_array( $response->items ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::warning( 'Invalid Webflow response: missing items array.' );
+ return $this->empty_batch();
+ }
+
+ $items = $response->items;
+ $count = count( $items );
+ $total = isset( $response->pagination->total ) ? (int) $response->pagination->total : ( $offset + $count );
+ $next = $offset + $count;
+ $has_next = $count > 0 && $next < $total;
+
+ $this->ensure_category_cache_loaded( $site_id );
+ $this->decorate_items_with_categories( $items );
+
+ // The cursor is simply the next offset. Unlike Shopify's stable cursors, offset paging can
+ // skip or double-import items if the Webflow catalog changes (a product added or removed)
+ // between batches. Acceptable here given Webflow's API offers no stable cursor.
+ return array(
+ 'items' => $items,
+ 'cursor' => $has_next ? (string) $next : null,
+ 'has_next_page' => $has_next,
+ );
+ }
+
+ /**
+ * Fetches the total count of products from Webflow.
+ *
+ * @param array $args Filter arguments (unused; Webflow's products endpoint does not accept filters).
+ * @return int Total product count, or 0 on failure.
+ */
+ public function fetch_total_count( array $args ): int {
+ unset( $args );
+ // Webflow does not support filtered count queries here.
+
+ $site_id = $this->webflow_client->get_site_id();
+ if ( is_wp_error( $site_id ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::warning( 'Could not fetch Webflow product count: ' . $site_id->get_error_message() );
+ return 0;
+ }
+
+ $response = $this->webflow_client->rest_request(
+ "/sites/{$site_id}/products",
+ array(
+ 'limit' => 1,
+ 'offset' => 0,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::warning( 'Could not fetch Webflow product count: ' . $response->get_error_message() );
+ return 0;
+ }
+
+ if ( ! is_object( $response ) || ! isset( $response->pagination->total ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::warning( 'Unexpected response from Webflow products endpoint - missing pagination.total.' );
+ return 0;
+ }
+
+ return (int) $response->pagination->total;
+ }
+
+ /**
+ * Decorate each product item with a `_resolved_categories` property whose value is an
+ * array of `{name, slug}` for every recognized CMS item id in `product.fieldData.category`.
+ *
+ * This is how the mapper learns about category names without needing access to the
+ * fetcher or making its own API calls. Missing/archived ids are silently dropped.
+ *
+ * @param array<int,object> $items Items array (mutated in place).
+ * @return void
+ */
+ private function decorate_items_with_categories( array &$items ): void {
+ if ( null === $this->category_cache || empty( $this->category_cache ) ) {
+ foreach ( $items as $item ) {
+ if ( is_object( $item ) ) {
+ // @phpstan-ignore-next-line property.notFound
+ $item->_resolved_categories = array();
+ }
+ }
+ return;
+ }
+
+ foreach ( $items as $item ) {
+ if ( ! is_object( $item ) ) {
+ continue;
+ }
+
+ $category_ids = array();
+ if ( isset( $item->product->fieldData->category ) && is_array( $item->product->fieldData->category ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ $category_ids = $item->product->fieldData->category; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ }
+
+ $resolved = array();
+ foreach ( $category_ids as $cms_item_id ) {
+ $cms_item_id = (string) $cms_item_id;
+ if ( isset( $this->category_cache[ $cms_item_id ] ) ) {
+ $resolved[] = $this->category_cache[ $cms_item_id ];
+ }
+ }
+
+ // @phpstan-ignore-next-line property.notFound
+ $item->_resolved_categories = $resolved;
+ }
+ }
+
+ /**
+ * Ensures the category cache has been resolved at most once per fetcher instance.
+ *
+ * @param string $site_id Webflow site ID.
+ * @return void
+ */
+ private function ensure_category_cache_loaded( string $site_id ): void {
+ if ( null !== $this->category_cache ) {
+ return;
+ }
+
+ $this->category_cache = array();
+
+ $collections_response = $this->webflow_client->rest_request( "/sites/{$site_id}/collections" );
+ if ( is_wp_error( $collections_response ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::debug( 'Could not load Webflow collections list (categories will not be resolved): ' . $collections_response->get_error_message() );
+ return;
+ }
+
+ $collections = $this->extract_collections_list( $collections_response );
+ $category_id = $this->find_category_collection_id( $collections );
+
+ if ( null === $category_id ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::debug( 'No "category" collection found on Webflow site; product categories will be skipped.' );
+ return;
+ }
+
+ $this->category_cache = $this->load_collection_items_map( $category_id );
+ }
+
+ /**
+ * Webflow's /collections endpoint sometimes returns a bare array and sometimes an envelope.
+ * Normalize to a flat list of collection objects.
+ *
+ * @param mixed $response The raw decoded API response.
+ * @return array<int,object>
+ */
+ private function extract_collections_list( $response ): array {
+ if ( is_array( $response ) ) {
+ return $response;
+ }
+
+ if ( is_object( $response ) && isset( $response->collections ) && is_array( $response->collections ) ) {
+ return $response->collections;
+ }
+
+ return array();
+ }
+
+ /**
+ * Find the auto-provisioned Ecommerce Categories collection.
+ *
+ * @param array<int,object> $collections List of collection objects.
+ * @return string|null Collection ID or null if not found.
+ */
+ private function find_category_collection_id( array $collections ): ?string {
+ foreach ( $collections as $collection ) {
+ if ( ! is_object( $collection ) || empty( $collection->id ) ) {
+ continue;
+ }
+ $slug = isset( $collection->slug ) ? (string) $collection->slug : '';
+ $display_name = isset( $collection->displayName ) ? (string) $collection->displayName : ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+
+ if ( 'category' === $slug || 'Categories' === $display_name ) {
+ return (string) $collection->id;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Page through a CMS collection and build an id => {name, slug} map.
+ *
+ * @param string $collection_id The Webflow CMS collection ID.
+ * @return array<string,array{name:string,slug:string}>
+ */
+ private function load_collection_items_map( string $collection_id ): array {
+ $map = array();
+ $offset = 0;
+ $page_size = self::MAX_PAGE_SIZE;
+ $max_pages = 100;
+ // Safety net to avoid runaway loops on broken pagination.
+ $page_index = 0;
+
+ while ( $page_index < $max_pages ) {
+ $response = $this->webflow_client->rest_request(
+ "/collections/{$collection_id}/items",
+ array(
+ 'limit' => $page_size,
+ 'offset' => $offset,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ // @phpstan-ignore-next-line class.notFound
+ \WP_CLI::debug( 'Could not load Webflow category items: ' . $response->get_error_message() );
+ break;
+ }
+
+ $items = ( is_object( $response ) && isset( $response->items ) && is_array( $response->items ) ) ? $response->items : array();
+ if ( empty( $items ) ) {
+ break;
+ }
+
+ foreach ( $items as $item ) {
+ if ( ! is_object( $item ) || empty( $item->id ) ) {
+ continue;
+ }
+ $field_data = isset( $item->fieldData ) && is_object( $item->fieldData ) ? $item->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ if ( null === $field_data ) {
+ continue;
+ }
+
+ $name = isset( $field_data->name ) ? (string) $field_data->name : '';
+ $slug = isset( $field_data->slug ) ? (string) $field_data->slug : sanitize_title( $name );
+
+ if ( '' === $name ) {
+ continue;
+ }
+
+ $map[ (string) $item->id ] = array(
+ 'name' => $name,
+ 'slug' => $slug,
+ );
+ }
+
+ $total = isset( $response->pagination->total ) ? (int) $response->pagination->total : ( $offset + count( $items ) );
+ $offset += count( $items );
+ if ( $offset >= $total ) {
+ break;
+ }
+
+ ++$page_index;
+ }
+
+ return $map;
+ }
+
+ /**
+ * Empty-batch response helper.
+ *
+ * @return array{items: array, cursor: null, has_next_page: false}
+ */
+ private function empty_batch(): array {
+ return array(
+ 'items' => array(),
+ 'cursor' => null,
+ 'has_next_page' => false,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowMapper.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowMapper.php
new file mode 100644
index 00000000000..11bb190bc89
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowMapper.php
@@ -0,0 +1,812 @@
+<?php
+/**
+ * Webflow Mapper
+ *
+ * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow;
+
+use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformMapperInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Transforms a raw Webflow product+SKUs payload into the standardized array
+ * consumed by WooCommerceProductImporter.
+ *
+ * Webflow's eCommerce model differs from Shopify's in several ways:
+ *
+ * - Variants ("SKUs") live nested under the product in a single response.
+ * - Variant options live in `product.fieldData['sku-properties']` as an array
+ * of `{ id, name, enum: [ { id, name, slug } ] }`. Each SKU's `sku-values`
+ * maps property id => enum id, so the mapper must resolve ids to human
+ * names before producing WC attributes / variation attributes.
+ * - Prices are integer minor units (e.g. cents). The unit lives alongside.
+ * - Categories are CMS item references that the fetcher pre-resolves onto
+ * the item as `_resolved_categories` (an array of `{name, slug}`).
+ * - Images come from `main-image` + `more-images` on each SKU, plus
+ * `more-images` on the product itself. They must all live in a single
+ * `images[]` array so the importer can build the original_id => attachment
+ * map that variations rely on.
+ *
+ * @internal This class is part of the CLI Migrator feature and should not be used directly.
+ */
+class WebflowMapper implements PlatformMapperInterface {
+
+ /**
+ * Fields to process during mapping (selected via --fields/--exclude-fields).
+ *
+ * @var array
+ */
+ private array $fields_to_process = array();
+
+ /**
+ * Map of ISO currency code => minor-unit count, lazily built from core's locale-info.
+ *
+ * @var array<string,int>|null
+ */
+ private ?array $currency_decimals = null;
+
+ /**
+ * Constructor.
+ *
+ * @param array $args Optional arguments. Recognized keys:
+ * - 'fields': array of field keys to process. Empty/missing means all.
+ */
+ public function __construct( array $args = array() ) {
+ $this->fields_to_process = $args['fields'] ?? array();
+ }
+
+ /**
+ * Maps a raw Webflow product+SKUs item into the importer's standardized array shape.
+ *
+ * @param object $platform_data Webflow product item: `{ product: { id, fieldData: ... }, skus: [...], _resolved_categories?: [...] }`.
+ * @return array Standardized data array.
+ */
+ public function map_product_data( object $platform_data ): array {
+ $product = $this->extract_product( $platform_data );
+ $field_data = $this->extract_field_data( $product );
+ $skus = $this->extract_skus( $platform_data );
+
+ $properties = $this->extract_sku_properties( $field_data );
+ // Needs properties AND more than one SKU. A single-SKU product is imported as simple,
+ // intentionally dropping its lone option (a one-value attribute adds no variation choice).
+ $is_variable = ! empty( $properties ) && count( $skus ) > 1;
+
+ $wc_data = $this->map_basic_fields( $product, $field_data, $is_variable );
+
+ $wc_data['categories'] = $this->should_process( 'categories' ) ? $this->map_categories( $platform_data ) : array();
+ $wc_data['tags'] = array();
+
+ $images = $this->should_process( 'images' ) ? $this->build_images( $field_data, $skus ) : array();
+ $wc_data['images'] = $images;
+
+ if ( $is_variable ) {
+ $wc_data = array_merge( $wc_data, $this->map_variable_data( $properties, $skus, $images ) );
+ } else {
+ $wc_data = array_merge( $wc_data, $this->map_simple_data( $skus ) );
+ $wc_data['attributes'] = array();
+ $wc_data['variations'] = array();
+ }
+
+ $wc_data['metafields'] = $this->map_seo( $field_data );
+
+ return $wc_data;
+ }
+
+ /**
+ * Webflow list-products items wrap a `product` object and a `skus` array.
+ *
+ * Accept either shape (the wrapped item, or a bare product object) so tests
+ * and downstream callers can be lenient.
+ *
+ * @param object $platform_data Raw item.
+ * @return object Product object (with fieldData).
+ */
+ private function extract_product( object $platform_data ): object {
+ if ( isset( $platform_data->product ) && is_object( $platform_data->product ) ) {
+ return $platform_data->product;
+ }
+ return $platform_data;
+ }
+
+ /**
+ * Returns the fieldData object on a product, or an empty stdClass for safety.
+ *
+ * @param object $product Product object.
+ * @return object
+ */
+ private function extract_field_data( object $product ): object {
+ if ( isset( $product->fieldData ) && is_object( $product->fieldData ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ return $product->fieldData; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ }
+ return new \stdClass();
+ }
+
+ /**
+ * Returns the SKUs array off a wrapped item, or empty array.
+ *
+ * @param object $platform_data Raw item.
+ * @return array<int,object>
+ */
+ private function extract_skus( object $platform_data ): array {
+ if ( isset( $platform_data->skus ) && is_array( $platform_data->skus ) ) {
+ return $platform_data->skus;
+ }
+ return array();
+ }
+
+ /**
+ * Returns the sku-properties array off fieldData, or empty array.
+ *
+ * @param object $field_data Field data.
+ * @return array<int,object>
+ */
+ private function extract_sku_properties( object $field_data ): array {
+ $key = 'sku-properties';
+ if ( isset( $field_data->{$key} ) && is_array( $field_data->{$key} ) ) {
+ return $field_data->{$key};
+ }
+ return array();
+ }
+
+ /**
+ * Map fields common to every product (name, slug, description, status…).
+ *
+ * @param object $product Product object.
+ * @param object $field_data Field data object.
+ * @param bool $is_variable Whether this product has multiple SKUs to expose as variations.
+ * @return array
+ */
+ private function map_basic_fields( object $product, object $field_data, bool $is_variable ): array {
+ $basic = array();
+
+ $basic['is_variable'] = $is_variable;
+ $basic['original_product_id'] = isset( $product->id ) ? (string) $product->id : null;
+
+ $basic['name'] = isset( $field_data->name ) ? sanitize_text_field( (string) $field_data->name ) : '';
+ $basic['slug'] = isset( $field_data->slug ) ? sanitize_title( (string) $field_data->slug ) : sanitize_title( $basic['name'] );
+ $basic['description'] = isset( $field_data->description ) ? wp_kses_post( (string) $field_data->description ) : '';
+
+ $short_description_key = 'short-description';
+ $basic['short_description'] = isset( $field_data->{$short_description_key} )
+ ? wp_kses_post( (string) $field_data->{$short_description_key} )
+ : '';
+
+ // Status and visibility mirror the source on every import, so a re-run resets any manual
+ // draft/hide a merchant applied to a previously imported product. This is intentional: the
+ // migrator treats Webflow as the source of truth for these fields.
+ $basic['status'] = $this->map_status( $product );
+ $basic['catalog_visibility'] = 'visible';
+
+ if ( isset( $product->createdOn ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ $basic['date_created_gmt'] = (string) $product->createdOn; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ }
+ if ( isset( $product->lastUpdated ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ $basic['date_modified_gmt'] = (string) $product->lastUpdated; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ }
+
+ $basic['brand'] = null;
+
+ return $basic;
+ }
+
+ /**
+ * Map Webflow product publication flags to WC status.
+ *
+ * @param object $product Product object.
+ * @return string
+ */
+ private function map_status( object $product ): string {
+ $is_archived = ! empty( $product->isArchived ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ $is_draft = ! empty( $product->isDraft ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+
+ if ( $is_archived ) {
+ return 'draft';
+ }
+ if ( $is_draft ) {
+ return 'draft';
+ }
+ return 'publish';
+ }
+
+ /**
+ * Read the pre-resolved categories the fetcher attached to the item.
+ *
+ * @param object $platform_data Raw item.
+ * @return array<int,array{name:string,slug:string}>
+ */
+ private function map_categories( object $platform_data ): array {
+ $resolved_key = '_resolved_categories';
+ if ( ! isset( $platform_data->{$resolved_key} ) || ! is_array( $platform_data->{$resolved_key} ) ) {
+ return array();
+ }
+
+ $categories = array();
+ foreach ( $platform_data->{$resolved_key} as $entry ) {
+ if ( is_array( $entry ) && ! empty( $entry['name'] ) ) {
+ $categories[] = array(
+ 'name' => sanitize_text_field( (string) $entry['name'] ),
+ 'slug' => sanitize_title( (string) ( $entry['slug'] ?? $entry['name'] ) ),
+ );
+ } elseif ( is_object( $entry ) && ! empty( $entry->name ) ) {
+ $categories[] = array(
+ 'name' => sanitize_text_field( (string) $entry->name ),
+ 'slug' => sanitize_title( (string) ( $entry->slug ?? $entry->name ) ),
+ );
+ }
+ }
+ return $categories;
+ }
+
+ /**
+ * Build the unified images array (product gallery + SKU images, deduped by URL).
+ *
+ * The importer requires every image referenced from a variation to also appear
+ * in this top-level array — that's how the original_id => attachment mapping is
+ * populated. We assign stable `original_id`s here (preferring Webflow's fileId,
+ * falling back to a URL hash) so SKU references resolve cleanly later.
+ *
+ * @param object $field_data Product fieldData.
+ * @param array<int,object> $skus SKUs array.
+ * @return array<int,array{original_id:string,src:string,alt:?string,is_featured:bool}>
+ */
+ private function build_images( object $field_data, array $skus ): array {
+ $images = array();
+ $by_url = array();
+ $featured_url = null;
+
+ // Product-level gallery: fieldData['more-images'].
+ $more_images_key = 'more-images';
+ if ( isset( $field_data->{$more_images_key} ) && is_array( $field_data->{$more_images_key} ) ) {
+ foreach ( $field_data->{$more_images_key} as $img ) {
+ $entry = $this->normalize_image_object( $img );
+ if ( null === $entry ) {
+ continue;
+ }
+ if ( null === $featured_url ) {
+ $featured_url = $entry['src'];
+ }
+ $this->add_unique_image( $images, $by_url, $entry );
+ }
+ }
+
+ // SKU main-image + more-images.
+ $main_image_key = 'main-image';
+ foreach ( $skus as $sku ) {
+ $sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ if ( null === $sku_field ) {
+ continue;
+ }
+
+ $main_entry = isset( $sku_field->{$main_image_key} ) ? $this->normalize_image_object( $sku_field->{$main_image_key} ) : null;
+ if ( $main_entry ) {
+ if ( null === $featured_url ) {
+ $featured_url = $main_entry['src'];
+ }
+ $this->add_unique_image( $images, $by_url, $main_entry );
+ }
+
+ if ( isset( $sku_field->{$more_images_key} ) && is_array( $sku_field->{$more_images_key} ) ) {
+ foreach ( $sku_field->{$more_images_key} as $img ) {
+ $entry = $this->normalize_image_object( $img );
+ if ( $entry ) {
+ $this->add_unique_image( $images, $by_url, $entry );
+ }
+ }
+ }
+ }
+
+ // Mark featured.
+ foreach ( $images as &$image ) {
+ $image['is_featured'] = ( $image['src'] === $featured_url );
+ }
+ unset( $image );
+
+ return $images;
+ }
+
+ /**
+ * Normalize a Webflow image object into our images-array entry, or return null if unusable.
+ *
+ * @param mixed $img Raw Webflow image entry.
+ * @return array{original_id:string,src:string,alt:?string,is_featured:bool}|null
+ */
+ private function normalize_image_object( $img ): ?array {
+ if ( ! is_object( $img ) ) {
+ return null;
+ }
+
+ $url = isset( $img->url ) ? (string) $img->url : '';
+ if ( '' === $url ) {
+ return null;
+ }
+
+ $alt = isset( $img->alt ) ? (string) $img->alt : null;
+
+ $original_id = '';
+ if ( isset( $img->fileId ) && '' !== (string) $img->fileId ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ $original_id = (string) $img->fileId; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ } elseif ( isset( $img->id ) && '' !== (string) $img->id ) {
+ $original_id = (string) $img->id;
+ } else {
+ $original_id = 'webflow-' . md5( $url );
+ }
+
+ return array(
+ 'original_id' => $original_id,
+ 'src' => $url,
+ 'alt' => $alt,
+ 'is_featured' => false,
+ );
+ }
+
+ /**
+ * Append an image entry to $images keyed by URL, deduping repeats.
+ *
+ * @param array $images Image list (mutated).
+ * @param array $by_url URL => index map (mutated).
+ * @param array $entry The image entry to add.
+ * @return void
+ */
+ private function add_unique_image( array &$images, array &$by_url, array $entry ): void {
+ $url = $entry['src'];
+ if ( isset( $by_url[ $url ] ) ) {
+ return;
+ }
+ $by_url[ $url ] = count( $images );
+ $images[] = $entry;
+ }
+
+ /**
+ * Map fields specific to a simple (single-SKU) product.
+ *
+ * @param array<int,object> $skus SKUs.
+ * @return array
+ */
+ private function map_simple_data( array $skus ): array {
+ $simple = array(
+ 'sku' => null,
+ 'regular_price' => null,
+ 'sale_price' => null,
+ 'manage_stock' => false,
+ 'stock_quantity' => null,
+ 'stock_status' => 'instock',
+ 'weight' => null,
+ 'tax_status' => 'taxable',
+ );
+
+ if ( empty( $skus ) ) {
+ return $simple;
+ }
+
+ $sku = $skus[0];
+ $sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ if ( null === $sku_field ) {
+ return $simple;
+ }
+
+ if ( $this->should_process( 'price' ) ) {
+ $prices = $this->extract_prices( $sku_field );
+ $simple['regular_price'] = $prices['regular_price'];
+ $simple['sale_price'] = $prices['sale_price'];
+ }
+
+ if ( $this->should_process( 'sku' ) && isset( $sku_field->sku ) ) {
+ $simple['sku'] = wc_clean( (string) $sku_field->sku );
+ }
+
+ if ( $this->should_process( 'stock' ) ) {
+ $stock = $this->extract_stock( $sku_field );
+ $simple['manage_stock'] = $stock['manage_stock'];
+ $simple['stock_quantity'] = $stock['stock_quantity'];
+ $simple['stock_status'] = $stock['stock_status'];
+ }
+
+ if ( $this->should_process( 'weight' ) ) {
+ $simple['weight'] = $this->extract_weight( $sku_field );
+ }
+
+ if ( $this->should_process( 'dimensions' ) ) {
+ $simple = array_merge( $simple, $this->extract_dimensions( $sku_field ) );
+ }
+
+ return $simple;
+ }
+
+ /**
+ * Map variable-product data: attribute definitions + per-variation rows.
+ *
+ * @param array<int,object> $properties sku-properties array.
+ * @param array<int,object> $skus SKUs array.
+ * @param array<int,array> $images Already-built images list (to resolve image references).
+ * @return array{attributes: array, variations: array}
+ */
+ private function map_variable_data( array $properties, array $skus, array $images ): array {
+ $attributes = array();
+
+ // Build a lookup: property_id => [ 'name' => string, 'enums' => [ enum_id => option_name ] ].
+ $property_lookup = array();
+ $position = 0;
+ foreach ( $properties as $property ) {
+ if ( ! is_object( $property ) || empty( $property->id ) || empty( $property->name ) ) {
+ continue;
+ }
+
+ $enum_map = array();
+ $enum_options = array();
+ $enums = ( isset( $property->enum ) && is_array( $property->enum ) ) ? $property->enum : array();
+
+ foreach ( $enums as $enum ) {
+ if ( ! is_object( $enum ) || empty( $enum->id ) || empty( $enum->name ) ) {
+ continue;
+ }
+ $enum_map[ (string) $enum->id ] = (string) $enum->name;
+ $enum_options[] = (string) $enum->name;
+ }
+
+ $property_lookup[ (string) $property->id ] = array(
+ 'name' => (string) $property->name,
+ 'enums' => $enum_map,
+ );
+
+ if ( $this->should_process( 'attributes' ) ) {
+ $attributes[] = array(
+ 'name' => wc_clean( (string) $property->name ),
+ 'options' => array_map( 'wc_clean', $enum_options ),
+ 'position' => $position,
+ 'is_visible' => true,
+ 'is_variation' => true,
+ );
+ }
+
+ ++$position;
+ }
+
+ // Build URL => original_id map so we can resolve a SKU's main-image back to an entry in images[].
+ $url_to_original_id = array();
+ foreach ( $images as $image ) {
+ $url_to_original_id[ $image['src'] ] = $image['original_id'];
+ }
+
+ $variations = array();
+ $menu_order = 0;
+ foreach ( $skus as $sku ) {
+ $sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ if ( null === $sku_field ) {
+ continue;
+ }
+
+ $variation = array(
+ 'original_id' => isset( $sku->id ) ? (string) $sku->id : null,
+ 'sku' => null,
+ 'regular_price' => null,
+ 'sale_price' => null,
+ 'manage_stock' => false,
+ 'stock_quantity' => null,
+ 'stock_status' => 'instock',
+ 'weight' => null,
+ 'tax_status' => 'taxable',
+ 'attributes' => array(),
+ 'image_original_id' => null,
+ 'menu_order' => $menu_order,
+ );
+
+ if ( $this->should_process( 'sku' ) && isset( $sku_field->sku ) ) {
+ $variation['sku'] = wc_clean( (string) $sku_field->sku );
+ }
+
+ if ( $this->should_process( 'price' ) ) {
+ $prices = $this->extract_prices( $sku_field );
+ $variation['regular_price'] = $prices['regular_price'];
+ $variation['sale_price'] = $prices['sale_price'];
+ }
+
+ if ( $this->should_process( 'stock' ) ) {
+ $stock = $this->extract_stock( $sku_field );
+ $variation['manage_stock'] = $stock['manage_stock'];
+ $variation['stock_quantity'] = $stock['stock_quantity'];
+ $variation['stock_status'] = $stock['stock_status'];
+ }
+
+ if ( $this->should_process( 'weight' ) ) {
+ $variation['weight'] = $this->extract_weight( $sku_field );
+ }
+
+ if ( $this->should_process( 'dimensions' ) ) {
+ $variation = array_merge( $variation, $this->extract_dimensions( $sku_field ) );
+ }
+
+ if ( $this->should_process( 'attributes' ) ) {
+ $variation['attributes'] = $this->resolve_variation_attributes( $sku_field, $property_lookup );
+ if ( empty( $variation['attributes'] ) ) {
+ wc_get_logger()->debug(
+ sprintf(
+ 'Webflow variation %s resolved to zero attributes; WooCommerce will store it as an "Any" variation, which can collide with sibling variations.',
+ $variation['original_id'] ?? 'unknown'
+ ),
+ array( 'source' => 'wc-migrator' )
+ );
+ }
+ }
+
+ if ( $this->should_process( 'images' ) ) {
+ $main_image_key = 'main-image';
+ if ( isset( $sku_field->{$main_image_key}->url ) ) {
+ $url = (string) $sku_field->{$main_image_key}->url;
+ if ( isset( $url_to_original_id[ $url ] ) ) {
+ $variation['image_original_id'] = $url_to_original_id[ $url ];
+ }
+ }
+ }
+
+ $variations[] = $variation;
+ ++$menu_order;
+ }
+
+ return array(
+ 'attributes' => $attributes,
+ 'variations' => $variations,
+ );
+ }
+
+ /**
+ * Resolve a SKU's `sku-values` (property_id => enum_id) into a human-readable
+ * `attribute_name => option_name` array using the property lookup.
+ *
+ * @param object $sku_field SKU fieldData.
+ * @param array $property_lookup Property lookup map.
+ * @return array<string,string>
+ */
+ private function resolve_variation_attributes( object $sku_field, array $property_lookup ): array {
+ $resolved = array();
+ $values_key = 'sku-values';
+ if ( ! isset( $sku_field->{$values_key} ) ) {
+ return $resolved;
+ }
+
+ $values = $sku_field->{$values_key};
+ if ( ! is_object( $values ) && ! is_array( $values ) ) {
+ return $resolved;
+ }
+
+ foreach ( (array) $values as $property_id => $enum_id ) {
+ $property_id = (string) $property_id;
+ $enum_id = (string) $enum_id;
+ if ( ! isset( $property_lookup[ $property_id ] ) ) {
+ continue;
+ }
+ $property_name = $property_lookup[ $property_id ]['name'];
+ $option_name = $property_lookup[ $property_id ]['enums'][ $enum_id ] ?? null;
+ if ( null === $option_name ) {
+ continue;
+ }
+ $resolved[ sanitize_text_field( $property_name ) ] = sanitize_text_field( $option_name );
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * Convert Webflow `price.value` (minor units, e.g. cents) into a decimal string,
+ * and detect sale pricing via `compare-at-price`.
+ *
+ * @param object $sku_field SKU fieldData.
+ * @return array{regular_price: ?string, sale_price: ?string}
+ */
+ private function extract_prices( object $sku_field ): array {
+ $price = $this->price_to_decimal( $sku_field->price ?? null );
+ $compare_key = 'compare-at-price';
+ $compare_price = $this->price_to_decimal( $sku_field->{$compare_key} ?? null );
+
+ if ( null !== $compare_price && null !== $price && (float) $compare_price > (float) $price ) {
+ return array(
+ 'regular_price' => $compare_price,
+ 'sale_price' => $price,
+ );
+ }
+
+ return array(
+ 'regular_price' => $price,
+ 'sale_price' => null,
+ );
+ }
+
+ /**
+ * Convert a Webflow money object `{ value: int (minor units), unit: "USD" }` into a decimal string.
+ *
+ * The number of minor units per major unit varies by currency (e.g. JPY has 0,
+ * KWD has 3, most have 2), so the divisor is derived from the currency's
+ * `num_decimals` rather than a hardcoded `/ 100`.
+ *
+ * @param mixed $money Webflow money object.
+ * @return string|null
+ */
+ private function price_to_decimal( $money ): ?string {
+ if ( ! is_object( $money ) || ! isset( $money->value ) ) {
+ return null;
+ }
+ $minor = (int) $money->value;
+ if ( $minor < 0 ) {
+ return null;
+ }
+ $unit = isset( $money->unit ) ? (string) $money->unit : 'USD';
+ $decimals = $this->get_currency_decimals( $unit );
+ return number_format( $minor / ( 10 ** $decimals ), $decimals, '.', '' );
+ }
+
+ /**
+ * Get the number of decimal places (minor units) for an ISO currency code.
+ *
+ * Reads core's `i18n/locale-info.php` so the migrator stays in sync with
+ * WooCommerce's own per-currency data. Falls back to 2 for unknown codes.
+ *
+ * @param string $currency ISO 4217 currency code.
+ * @return int
+ */
+ private function get_currency_decimals( string $currency ): int {
+ if ( null === $this->currency_decimals ) {
+ $this->currency_decimals = array();
+ $locale_info = include WC()->plugin_path() . '/i18n/locale-info.php';
+ if ( is_array( $locale_info ) ) {
+ foreach ( $locale_info as $info ) {
+ if ( isset( $info['currency_code'], $info['num_decimals'] ) ) {
+ $this->currency_decimals[ $info['currency_code'] ] = (int) $info['num_decimals'];
+ }
+ }
+ }
+ }
+ return $this->currency_decimals[ strtoupper( $currency ) ] ?? 2;
+ }
+
+ /**
+ * Map Webflow inventory shape to WC stock fields.
+ *
+ * @param object $sku_field SKU fieldData.
+ * @return array{manage_stock: bool, stock_quantity: ?int, stock_status: string}
+ */
+ private function extract_stock( object $sku_field ): array {
+ $inventory = isset( $sku_field->inventory ) && is_object( $sku_field->inventory ) ? $sku_field->inventory : null;
+
+ if ( null === $inventory ) {
+ return array(
+ 'manage_stock' => false,
+ 'stock_quantity' => null,
+ 'stock_status' => 'instock',
+ );
+ }
+
+ $type = isset( $inventory->type ) ? (string) $inventory->type : 'infinite';
+ if ( 'finite' !== $type ) {
+ return array(
+ 'manage_stock' => false,
+ 'stock_quantity' => null,
+ 'stock_status' => 'instock',
+ );
+ }
+
+ $quantity = isset( $inventory->quantity ) ? (int) $inventory->quantity : 0;
+ return array(
+ 'manage_stock' => true,
+ 'stock_quantity' => $quantity,
+ 'stock_status' => $quantity > 0 ? 'instock' : 'outofstock',
+ );
+ }
+
+ /**
+ * Extract Webflow SKU dimensions (length, width, height).
+ *
+ * Webflow returns these as raw numerics with no associated unit on the SKU
+ * payload — the unit is a store-level setting on the Webflow side, with no
+ * API representation. We pass through as-is; WooCommerce will interpret them
+ * in whatever `woocommerce_dimension_unit` is configured for the destination
+ * store. Null/zero/non-numeric values are dropped.
+ *
+ * @param object $sku_field SKU fieldData.
+ * @return array{length: ?float, width: ?float, height: ?float}
+ */
+ private function extract_dimensions( object $sku_field ): array {
+ $dimensions = array(
+ 'length' => null,
+ 'width' => null,
+ 'height' => null,
+ );
+ foreach ( array_keys( $dimensions ) as $key ) {
+ if ( isset( $sku_field->{$key} ) && is_numeric( $sku_field->{$key} ) && (float) $sku_field->{$key} > 0 ) {
+ $dimensions[ $key ] = (float) $sku_field->{$key};
+ }
+ }
+ return $dimensions;
+ }
+
+ /**
+ * Convert Webflow `weight` (with optional `weight-unit`) to the store's weight unit.
+ *
+ * @param object $sku_field SKU fieldData.
+ * @return float|null
+ */
+ private function extract_weight( object $sku_field ): ?float {
+ if ( ! isset( $sku_field->weight ) ) {
+ return null;
+ }
+
+ $weight = (float) $sku_field->weight;
+ if ( $weight <= 0 ) {
+ return null;
+ }
+
+ $unit_key = 'weight-unit';
+ $source_unit = isset( $sku_field->{$unit_key} ) ? strtolower( (string) $sku_field->{$unit_key} ) : 'lbs';
+ $source_unit = $this->normalize_weight_unit( $source_unit );
+
+ $store_unit = strtolower( (string) get_option( 'woocommerce_weight_unit', 'kg' ) );
+
+ if ( $source_unit === $store_unit ) {
+ return $weight;
+ }
+
+ if ( function_exists( 'wc_get_weight' ) ) {
+ $converted = wc_get_weight( $weight, $store_unit, $source_unit );
+ return is_numeric( $converted ) ? (float) $converted : $weight;
+ }
+
+ return $weight;
+ }
+
+ /**
+ * Normalize a Webflow weight unit string to a wc_get_weight compatible unit.
+ *
+ * @param string $unit Raw unit string.
+ * @return string
+ */
+ private function normalize_weight_unit( string $unit ): string {
+ $map = array(
+ 'oz' => 'oz',
+ 'lb' => 'lbs',
+ 'lbs' => 'lbs',
+ 'pound' => 'lbs',
+ 'g' => 'g',
+ 'gram' => 'g',
+ 'kg' => 'kg',
+ );
+ return $map[ $unit ] ?? 'lbs';
+ }
+
+ /**
+ * Map Webflow SEO fields (`seo-title`, `seo-description`) into a metafields array.
+ *
+ * @param object $field_data Field data.
+ * @return array<string,string>
+ */
+ private function map_seo( object $field_data ): array {
+ $meta = array();
+
+ $title_key = 'seo-title';
+ $desc_key = 'seo-description';
+
+ if ( isset( $field_data->{$title_key} ) && '' !== (string) $field_data->{$title_key} ) {
+ $meta['global_title_tag'] = (string) $field_data->{$title_key};
+ }
+ if ( isset( $field_data->{$desc_key} ) && '' !== (string) $field_data->{$desc_key} ) {
+ $meta['global_description_tag'] = (string) $field_data->{$desc_key};
+ }
+
+ return $meta;
+ }
+
+ /**
+ * Should this field be processed?
+ *
+ * @param string $field_key The field key.
+ * @return bool
+ */
+ private function should_process( string $field_key ): bool {
+ if ( empty( $this->fields_to_process ) ) {
+ return true;
+ }
+ return in_array( $field_key, $this->fields_to_process, true );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowPlatform.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowPlatform.php
new file mode 100644
index 00000000000..65a5eec617d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowPlatform.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Webflow Platform Registration
+ *
+ * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * WebflowPlatform class.
+ *
+ * Registers the Webflow source platform with the WooCommerce Migrator's
+ * platform registry. Users authenticate with a Webflow Site Access Token.
+ *
+ * @internal
+ */
+class WebflowPlatform {
+
+ /**
+ * Initializes the Webflow platform registration.
+ *
+ * @internal
+ */
+ final public static function init(): void {
+ add_filter( 'woocommerce_migrator_platforms', array( self::class, 'register_platform' ) );
+ }
+
+ /**
+ * Registers the Webflow platform with the migrator system.
+ *
+ * @param array $platforms Array of registered platforms.
+ * @return array Updated array of platforms including Webflow.
+ */
+ public static function register_platform( array $platforms ): array {
+ $platforms['webflow'] = array(
+ 'name' => 'Webflow',
+ 'description' => 'Import products and data from Webflow eCommerce sites',
+ 'fetcher' => WebflowFetcher::class,
+ 'mapper' => WebflowMapper::class,
+ 'credentials' => array(
+ 'site_id' => 'Enter Webflow site ID:',
+ 'access_token' => 'Enter Webflow Site Access Token (must include ecommerce:read and cms:read scopes):',
+ ),
+ );
+
+ return $platforms;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Runner.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Runner.php
index f898faaf95a..e153306dd40 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Runner.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Runner.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ResetCommand;
use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\SetupCommand;
use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ListCommand;
use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify\ShopifyPlatform;
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowPlatform;
use WP_CLI;
use WC_Product_Factory;
@@ -69,5 +70,6 @@ final class Runner {
*/
private static function init_platforms(): void {
ShopifyPlatform::init();
+ WebflowPlatform::init();
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Core/WooCommerceProductImporterTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Core/WooCommerceProductImporterTest.php
index c1b9a404577..d358b4746f1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Core/WooCommerceProductImporterTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Core/WooCommerceProductImporterTest.php
@@ -765,6 +765,91 @@ class WooCommerceProductImporterTest extends \WC_Unit_Test_Case {
$this->assertEquals( 'Found by Original ID Priority', $updated_product->get_name() );
}
+ /**
+ * Re-importing without weight/dimension keys must preserve the existing values.
+ *
+ * The importer is shared across platforms; mappers that do not emit a field
+ * (e.g. Shopify never maps length/width/height) must not clear merchant data
+ * on a re-run. Regression test for the update path.
+ */
+ public function test_update_preserves_weight_and_dimensions_when_keys_absent(): void {
+ $create_data = array(
+ 'name' => 'Dimension Product',
+ 'sku' => 'TEST-SKU-DIM-1',
+ 'weight' => '2.5',
+ 'length' => '10',
+ 'width' => '5',
+ 'height' => '3',
+ );
+
+ $result1 = $this->importer->import_product( $create_data );
+ $this->assertEquals( 'created', $result1['action'] );
+ $product_id = $result1['product_id'];
+
+ // Re-import the same product without any weight/dimension keys.
+ $update_data = array(
+ 'name' => 'Dimension Product Updated',
+ 'sku' => 'TEST-SKU-DIM-1',
+ );
+
+ $update_importer = new WooCommerceProductImporter();
+ $update_importer->configure( array( 'update_existing' => true ) );
+
+ $result2 = $update_importer->import_product( $update_data );
+ $this->assertEquals( 'updated', $result2['action'] );
+ $this->assertEquals( $product_id, $result2['product_id'] );
+
+ // Existing weight/dimensions must be preserved, not wiped.
+ $product = wc_get_product( $product_id );
+ $this->assertSame( '2.5', $product->get_weight() );
+ $this->assertSame( '10', $product->get_length() );
+ $this->assertSame( '5', $product->get_width() );
+ $this->assertSame( '3', $product->get_height() );
+ }
+
+ /**
+ * Re-importing with explicitly empty weight/dimension keys must clear the values.
+ *
+ * When a mapper does emit the key (so the platform tracks the field), an empty
+ * value should mirror the source and clear the existing value.
+ */
+ public function test_update_clears_weight_and_dimensions_when_keys_present_empty(): void {
+ $create_data = array(
+ 'name' => 'Dimension Product Clear',
+ 'sku' => 'TEST-SKU-DIM-2',
+ 'weight' => '2.5',
+ 'length' => '10',
+ 'width' => '5',
+ 'height' => '3',
+ );
+
+ $result1 = $this->importer->import_product( $create_data );
+ $this->assertEquals( 'created', $result1['action'] );
+ $product_id = $result1['product_id'];
+
+ // Re-import with the keys present but empty/null.
+ $update_data = array(
+ 'name' => 'Dimension Product Clear',
+ 'sku' => 'TEST-SKU-DIM-2',
+ 'weight' => null,
+ 'length' => '',
+ 'width' => '',
+ 'height' => '',
+ );
+
+ $update_importer = new WooCommerceProductImporter();
+ $update_importer->configure( array( 'update_existing' => true ) );
+
+ $result2 = $update_importer->import_product( $update_data );
+ $this->assertEquals( 'updated', $result2['action'] );
+
+ $product = wc_get_product( $product_id );
+ $this->assertSame( '', $product->get_weight() );
+ $this->assertSame( '', $product->get_length() );
+ $this->assertSame( '', $product->get_width() );
+ $this->assertSame( '', $product->get_height() );
+ }
+
/**
* Helper method to clean up test products.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/Fixtures/MockWebflowData.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/Fixtures/MockWebflowData.php
new file mode 100644
index 00000000000..d4311859cab
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/Fixtures/MockWebflowData.php
@@ -0,0 +1,339 @@
+<?php
+/**
+ * Mock Webflow API response data for use in unit tests.
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow\Fixtures
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow\Fixtures;
+
+/**
+ * Canned Webflow API payloads used across mapper/fetcher tests.
+ *
+ * Each method returns a freshly-decoded object so tests can mutate without
+ * affecting one another. Shapes mirror what `/v2/sites/{id}/products` returns.
+ */
+class MockWebflowData {
+
+ /**
+ * Decode a JSON string to objects (mirrors what WebflowClient returns).
+ *
+ * @param string $json JSON string.
+ * @return object
+ */
+ private static function decode( string $json ): object {
+ return json_decode( $json );
+ }
+
+ /**
+ * Single non-variant product (one SKU, no sku-properties).
+ *
+ * @return object
+ */
+ public static function simple_product_item(): object {
+ return self::decode(
+ <<<'JSON'
+ {
+ "product": {
+ "id": "prod-simple-1",
+ "isArchived": false,
+ "isDraft": false,
+ "createdOn": "2024-01-01T00:00:00Z",
+ "lastUpdated": "2024-01-02T00:00:00Z",
+ "fieldData": {
+ "name": "Plain Tee",
+ "slug": "plain-tee",
+ "description": "<p>Just a plain tee.</p>",
+ "short-description": "A tee.",
+ "seo-title": "Plain Tee — Buy Now",
+ "seo-description": "The plainest tee.",
+ "category": ["cat-shirts"],
+ "more-images": [
+ { "fileId": "img-prod-1", "url": "https://cdn.webflow.test/prod-1.jpg", "alt": "front" }
+ ]
+ }
+ },
+ "skus": [
+ {
+ "id": "sku-simple-1",
+ "fieldData": {
+ "sku": "PLAIN-001",
+ "price": { "value": 1999, "unit": "USD" },
+ "compare-at-price": { "value": 2499, "unit": "USD" },
+ "inventory": { "type": "finite", "quantity": 7 },
+ "weight": 0.5,
+ "weight-unit": "lb",
+ "length": 10,
+ "width": 5,
+ "height": 2,
+ "main-image": { "fileId": "img-prod-1", "url": "https://cdn.webflow.test/prod-1.jpg", "alt": "front" }
+ }
+ }
+ ],
+ "_resolved_categories": [
+ { "name": "Shirts", "slug": "shirts" }
+ ]
+ }
+ JSON
+ );
+ }
+
+ /**
+ * Variable product with two sku-properties (Color, Size) and four SKUs,
+ * each with its own main-image. Includes infinite inventory on one SKU.
+ *
+ * @return object
+ */
+ public static function variable_product_item(): object {
+ return self::decode(
+ <<<'JSON'
+ {
+ "product": {
+ "id": "prod-var-1",
+ "isArchived": false,
+ "isDraft": false,
+ "createdOn": "2024-03-01T00:00:00Z",
+ "fieldData": {
+ "name": "Fancy Hoodie",
+ "slug": "fancy-hoodie",
+ "description": "<p>Hoodie.</p>",
+ "category": ["cat-outerwear"],
+ "sku-properties": [
+ {
+ "id": "prop-color",
+ "name": "Color",
+ "enum": [
+ { "id": "enum-red", "name": "Red", "slug": "red" },
+ { "id": "enum-blue", "name": "Blue", "slug": "blue" }
+ ]
+ },
+ {
+ "id": "prop-size",
+ "name": "Size",
+ "enum": [
+ { "id": "enum-s", "name": "S", "slug": "s" },
+ { "id": "enum-m", "name": "M", "slug": "m" }
+ ]
+ }
+ ],
+ "more-images": [
+ { "fileId": "img-gallery", "url": "https://cdn.webflow.test/gallery.jpg", "alt": "gallery" }
+ ]
+ }
+ },
+ "skus": [
+ {
+ "id": "sku-red-s",
+ "fieldData": {
+ "sku": "HOOD-RED-S",
+ "price": { "value": 4999, "unit": "USD" },
+ "inventory": { "type": "finite", "quantity": 3 },
+ "length": 20,
+ "width": 15,
+ "height": 8,
+ "sku-values": { "prop-color": "enum-red", "prop-size": "enum-s" },
+ "main-image": { "fileId": "img-red", "url": "https://cdn.webflow.test/red.jpg", "alt": "red" }
+ }
+ },
+ {
+ "id": "sku-red-m",
+ "fieldData": {
+ "sku": "HOOD-RED-M",
+ "price": { "value": 4999, "unit": "USD" },
+ "inventory": { "type": "infinite" },
+ "sku-values": { "prop-color": "enum-red", "prop-size": "enum-m" },
+ "main-image": { "fileId": "img-red", "url": "https://cdn.webflow.test/red.jpg", "alt": "red" }
+ }
+ },
+ {
+ "id": "sku-blue-s",
+ "fieldData": {
+ "sku": "HOOD-BLUE-S",
+ "price": { "value": 5499, "unit": "USD" },
+ "compare-at-price": { "value": 5999, "unit": "USD" },
+ "inventory": { "type": "finite", "quantity": 0 },
+ "sku-values": { "prop-color": "enum-blue", "prop-size": "enum-s" },
+ "main-image": { "fileId": "img-blue", "url": "https://cdn.webflow.test/blue.jpg", "alt": "blue" }
+ }
+ },
+ {
+ "id": "sku-blue-m",
+ "fieldData": {
+ "sku": "HOOD-BLUE-M",
+ "price": { "value": 5499, "unit": "USD" },
+ "inventory": { "type": "finite", "quantity": 10 },
+ "sku-values": { "prop-color": "enum-blue", "prop-size": "enum-m" },
+ "main-image": { "fileId": "img-blue", "url": "https://cdn.webflow.test/blue.jpg", "alt": "blue" }
+ }
+ }
+ ],
+ "_resolved_categories": [
+ { "name": "Outerwear", "slug": "outerwear" }
+ ]
+ }
+ JSON
+ );
+ }
+
+ /**
+ * Archived product — should map to draft status.
+ *
+ * @return object
+ */
+ public static function archived_product_item(): object {
+ $item = self::simple_product_item();
+ $item->product->isArchived = true; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
+ $item->product->id = 'prod-archived-1';
+ return $item;
+ }
+
+ /**
+ * Raw products-list response body (two products, total 7).
+ *
+ * @return string
+ */
+ public static function products_list_response_body(): string {
+ return wp_json_encode(
+ array(
+ 'items' => array(
+ json_decode( wp_json_encode( self::simple_product_item() ) ),
+ json_decode( wp_json_encode( self::variable_product_item() ) ),
+ ),
+ 'pagination' => array(
+ 'limit' => 2,
+ 'offset' => 0,
+ 'total' => 7,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Empty products-list response (total 0).
+ *
+ * @return string
+ */
+ public static function empty_products_list_response_body(): string {
+ return wp_json_encode(
+ array(
+ 'items' => array(),
+ 'pagination' => array(
+ 'limit' => 1,
+ 'offset' => 0,
+ 'total' => 0,
+ ),
+ )
+ );
+ }
+
+ /**
+ * First page of the categories collection (cat-outerwear), total spanning two pages.
+ *
+ * @return string
+ */
+ public static function categories_collection_items_page_one_body(): string {
+ return wp_json_encode(
+ array(
+ 'items' => array(
+ array(
+ 'id' => 'cat-outerwear',
+ 'fieldData' => array(
+ 'name' => 'Outerwear',
+ 'slug' => 'outerwear',
+ ),
+ ),
+ ),
+ 'pagination' => array(
+ 'limit' => 100,
+ 'offset' => 0,
+ 'total' => 2,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Second page of the categories collection (cat-shirts), completing the two-page set.
+ *
+ * @return string
+ */
+ public static function categories_collection_items_page_two_body(): string {
+ return wp_json_encode(
+ array(
+ 'items' => array(
+ array(
+ 'id' => 'cat-shirts',
+ 'fieldData' => array(
+ 'name' => 'Shirts',
+ 'slug' => 'shirts',
+ ),
+ ),
+ ),
+ 'pagination' => array(
+ 'limit' => 100,
+ 'offset' => 1,
+ 'total' => 2,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Collections list response (one categories collection, one unrelated).
+ *
+ * @return string
+ */
+ public static function collections_list_response_body(): string {
+ return wp_json_encode(
+ array(
+ 'collections' => array(
+ array(
+ 'id' => 'coll-blog',
+ 'slug' => 'posts',
+ 'displayName' => 'Blog Posts',
+ ),
+ array(
+ 'id' => 'coll-cats',
+ 'slug' => 'category',
+ 'displayName' => 'Categories',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Collection items response for the categories collection.
+ *
+ * @return string
+ */
+ public static function categories_collection_items_response_body(): string {
+ return wp_json_encode(
+ array(
+ 'items' => array(
+ array(
+ 'id' => 'cat-shirts',
+ 'fieldData' => array(
+ 'name' => 'Shirts',
+ 'slug' => 'shirts',
+ ),
+ ),
+ array(
+ 'id' => 'cat-outerwear',
+ 'fieldData' => array(
+ 'name' => 'Outerwear',
+ 'slug' => 'outerwear',
+ ),
+ ),
+ ),
+ 'pagination' => array(
+ 'limit' => 100,
+ 'offset' => 0,
+ 'total' => 2,
+ ),
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowClientTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowClientTest.php
new file mode 100644
index 00000000000..164822ade7b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowClientTest.php
@@ -0,0 +1,258 @@
+<?php
+/**
+ * Webflow Client Test
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow;
+
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowClient;
+use WC_Unit_Test_Case;
+use WP_Error;
+
+// Shadows sleep() in the WebflowClient namespace so retry/backoff tests run instantly.
+require_once __DIR__ . '/functions-mock.php';
+
+/**
+ * Tests for WebflowClient.
+ */
+class WebflowClientTest extends WC_Unit_Test_Case {
+
+ /**
+ * Default test credentials.
+ *
+ * @var array
+ */
+ private array $test_credentials;
+
+ /**
+ * Client under test.
+ *
+ * @var WebflowClient
+ */
+ private WebflowClient $client;
+
+ /**
+ * HTTP filter callbacks registered during the test, removed in tearDown.
+ *
+ * @var array<callable>
+ */
+ private array $http_filters = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->http_filters = array();
+ $this->test_credentials = array(
+ 'site_id' => 'site-123',
+ 'access_token' => 'ws-test-token',
+ );
+ $this->client = new WebflowClient( $this->test_credentials );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ foreach ( $this->http_filters as $cb ) {
+ remove_filter( 'pre_http_request', $cb );
+ }
+ $this->http_filters = array();
+ parent::tearDown();
+ }
+
+ /**
+ * Test that a successful REST request returns the decoded body.
+ */
+ public function test_rest_request_success(): void {
+ $this->add_http_filter(
+ function ( $preempt, $args, $url ) {
+ unset( $preempt );
+ $this->assertStringContainsString( 'api.webflow.com/v2/sites/site-123/products', $url );
+ $this->assertSame( 'Bearer ws-test-token', $args['headers']['Authorization'] );
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode(
+ array(
+ 'items' => array(),
+ 'pagination' => array( 'total' => 0 ),
+ )
+ ),
+ );
+ },
+ 3
+ );
+
+ $result = $this->client->rest_request( '/sites/site-123/products' );
+
+ $this->assertNotInstanceOf( WP_Error::class, $result );
+ $this->assertIsObject( $result );
+ $this->assertSame( 0, (int) $result->pagination->total );
+ }
+
+ /**
+ * Test that query parameters end up in the URL.
+ */
+ public function test_rest_request_with_query_params(): void {
+ $this->add_http_filter(
+ function ( $preempt, $args, $url ) {
+ unset( $preempt, $args );
+ $this->assertStringContainsString( 'limit=10', $url );
+ $this->assertStringContainsString( 'offset=20', $url );
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode( array( 'ok' => true ) ),
+ );
+ },
+ 3
+ );
+
+ $result = $this->client->rest_request(
+ '/sites/site-123/products',
+ array(
+ 'limit' => 10,
+ 'offset' => 20,
+ )
+ );
+
+ $this->assertNotInstanceOf( WP_Error::class, $result );
+ }
+
+ /**
+ * Test that missing credentials produce a WP_Error.
+ */
+ public function test_rest_request_missing_credentials(): void {
+ $empty_client = new WebflowClient( array() );
+
+ $result = $empty_client->rest_request( '/sites/whatever/products' );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'api_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'not configured', $result->get_error_message() );
+ }
+
+ /**
+ * Test that HTTP errors surface as WP_Error.
+ */
+ public function test_rest_request_http_error(): void {
+ $this->add_http_filter(
+ function () {
+ return new WP_Error( 'http_request_failed', 'Connection refused' );
+ }
+ );
+
+ $result = $this->client->rest_request( '/sites/site-123/products' );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertStringContainsString( 'Connection refused', $result->get_error_message() );
+ }
+
+ /**
+ * Test that a non-2xx API response surfaces as WP_Error with the API message.
+ */
+ public function test_rest_request_api_error(): void {
+ $this->add_http_filter(
+ function () {
+ return array(
+ 'response' => array( 'code' => 403 ),
+ 'body' => wp_json_encode( array( 'message' => 'OAuthForbidden' ) ),
+ );
+ }
+ );
+
+ $result = $this->client->rest_request( '/sites/site-123/products' );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertStringContainsString( '403', $result->get_error_message() );
+ $this->assertStringContainsString( 'OAuthForbidden', $result->get_error_message() );
+ }
+
+ /**
+ * Test that a 429 response is retried and a following success is returned.
+ *
+ * Also exercises the retry-after header parsing. sleep() is shadowed (see
+ * functions-mock.php), so this does not actually wait.
+ */
+ public function test_rest_request_retries_on_429_then_succeeds(): void {
+ $attempts = 0;
+ $this->add_http_filter(
+ function () use ( &$attempts ) {
+ ++$attempts;
+ if ( 1 === $attempts ) {
+ return array(
+ 'response' => array( 'code' => 429 ),
+ 'headers' => array( 'retry-after' => '1' ),
+ 'body' => wp_json_encode( array( 'message' => 'TooManyRequests' ) ),
+ );
+ }
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode(
+ array(
+ 'items' => array(),
+ 'pagination' => array( 'total' => 0 ),
+ )
+ ),
+ );
+ }
+ );
+
+ $result = $this->client->rest_request( '/sites/site-123/products' );
+
+ $this->assertSame( 2, $attempts );
+ $this->assertNotInstanceOf( WP_Error::class, $result );
+ $this->assertIsObject( $result );
+ $this->assertSame( 0, (int) $result->pagination->total );
+ }
+
+ /**
+ * Test that repeated 429 responses exhaust the retry budget and surface a WP_Error.
+ */
+ public function test_rest_request_exhausts_retry_budget_on_repeated_429(): void {
+ $attempts = 0;
+ $this->add_http_filter(
+ function () use ( &$attempts ) {
+ ++$attempts;
+ return array(
+ 'response' => array( 'code' => 429 ),
+ 'headers' => array( 'retry-after' => '1' ),
+ 'body' => wp_json_encode( array( 'message' => 'TooManyRequests' ) ),
+ );
+ }
+ );
+
+ $result = $this->client->rest_request( '/sites/site-123/products' );
+
+ // Initial attempt + MAX_RETRIES (3) retries = 4 requests.
+ $this->assertSame( 4, $attempts );
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'api_error', $result->get_error_code() );
+ $this->assertStringContainsString( 'retry budget', $result->get_error_message() );
+ }
+
+ /**
+ * Test that get_site_id() returns the configured ID, or WP_Error if missing.
+ */
+ public function test_get_site_id(): void {
+ $this->assertSame( 'site-123', $this->client->get_site_id() );
+
+ $empty = new WebflowClient( array() );
+ $this->assertInstanceOf( WP_Error::class, $empty->get_site_id() );
+ }
+
+ /**
+ * Register a pre_http_request filter and track it for cleanup.
+ *
+ * @param callable $callback Filter callback.
+ * @param int $args_count Number of accepted arguments (default 1).
+ */
+ private function add_http_filter( callable $callback, int $args_count = 1 ): void {
+ add_filter( 'pre_http_request', $callback, 10, $args_count );
+ $this->http_filters[] = $callback;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowFetcherTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowFetcherTest.php
new file mode 100644
index 00000000000..743cc720bd2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowFetcherTest.php
@@ -0,0 +1,359 @@
+<?php
+/**
+ * Webflow Fetcher Test
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow;
+
+use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformFetcherInterface;
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowFetcher;
+use Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow\Fixtures\MockWebflowData;
+use WC_Unit_Test_Case;
+
+require_once __DIR__ . '/Fixtures/MockWebflowData.php';
+
+/**
+ * Tests for WebflowFetcher.
+ *
+ * Uses pre_http_request filters to mock the Webflow API responses, so no
+ * network calls are made.
+ */
+class WebflowFetcherTest extends WC_Unit_Test_Case {
+
+ /**
+ * The fetcher under test.
+ *
+ * @var WebflowFetcher
+ */
+ private WebflowFetcher $fetcher;
+
+ /**
+ * HTTP filter callbacks registered during the test, removed in tearDown.
+ *
+ * @var array<callable>
+ */
+ private array $http_filters = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->http_filters = array();
+ $this->fetcher = new WebflowFetcher(
+ array(
+ 'site_id' => 'site-123',
+ 'access_token' => 'ws-test-token',
+ )
+ );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ foreach ( $this->http_filters as $cb ) {
+ remove_filter( 'pre_http_request', $cb );
+ }
+ $this->http_filters = array();
+ parent::tearDown();
+ }
+
+ /**
+ * Test that WebflowFetcher implements the platform fetcher interface.
+ */
+ public function test_implements_platform_fetcher_interface(): void {
+ $this->assertInstanceOf( PlatformFetcherInterface::class, $this->fetcher );
+ }
+
+ /**
+ * Install a router that maps endpoint substrings to canned response bodies.
+ *
+ * @param array<string,string> $routes URL substring => response body JSON.
+ * @return void
+ */
+ private function install_http_router( array $routes ): void {
+ $cb = function ( $preempt, $args, $url ) use ( $routes ) {
+ unset( $preempt, $args );
+ foreach ( $routes as $needle => $body ) {
+ if ( false !== strpos( $url, $needle ) ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => $body,
+ );
+ }
+ }
+ return array(
+ 'response' => array( 'code' => 404 ),
+ 'body' => wp_json_encode( array( 'message' => 'Not stubbed: ' . $url ) ),
+ );
+ };
+
+ add_filter( 'pre_http_request', $cb, 10, 3 );
+ $this->http_filters[] = $cb;
+ }
+
+ /**
+ * Test fetch_batch returns items plus a next-offset cursor when more pages exist.
+ */
+ public function test_fetch_batch_returns_items_and_cursor(): void {
+ $this->install_http_router(
+ array(
+ '/sites/site-123/products' => MockWebflowData::products_list_response_body(),
+ '/sites/site-123/collections' => MockWebflowData::collections_list_response_body(),
+ '/collections/coll-cats/items' => MockWebflowData::categories_collection_items_response_body(),
+ )
+ );
+
+ $result = $this->fetcher->fetch_batch( array( 'limit' => 2 ) );
+
+ $this->assertIsArray( $result );
+ $this->assertCount( 2, $result['items'] );
+ $this->assertSame( '2', $result['cursor'], 'Next cursor should be the stringified next-offset.' );
+ $this->assertTrue( $result['has_next_page'] );
+ }
+
+ /**
+ * Test that the after_cursor argument is translated back into an offset query param.
+ */
+ public function test_fetch_batch_translates_cursor_to_offset(): void {
+ $captured_urls = array();
+
+ $cb = function ( $preempt, $args, $url ) use ( &$captured_urls ) {
+ unset( $preempt, $args );
+ $captured_urls[] = $url;
+ if ( false !== strpos( $url, '/products' ) ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => MockWebflowData::empty_products_list_response_body(),
+ );
+ }
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode( array( 'collections' => array() ) ),
+ );
+ };
+
+ add_filter( 'pre_http_request', $cb, 10, 3 );
+ $this->http_filters[] = $cb;
+
+ $this->fetcher->fetch_batch(
+ array(
+ 'limit' => 5,
+ 'after_cursor' => '42',
+ )
+ );
+
+ $products_urls = array_filter(
+ $captured_urls,
+ function ( $url ) {
+ return false !== strpos( $url, '/products' );
+ }
+ );
+ $this->assertNotEmpty( $products_urls );
+ $products_url = array_values( $products_urls )[0];
+ $this->assertStringContainsString( 'offset=42', $products_url );
+ $this->assertStringContainsString( 'limit=5', $products_url );
+ }
+
+ /**
+ * Test that has_next_page is false when the response is the last page.
+ */
+ public function test_fetch_batch_no_next_page_when_total_reached(): void {
+ $body = wp_json_encode(
+ array(
+ 'items' => array(
+ json_decode( wp_json_encode( MockWebflowData::simple_product_item() ) ),
+ ),
+ 'pagination' => array(
+ 'limit' => 1,
+ 'offset' => 0,
+ 'total' => 1,
+ ),
+ )
+ );
+
+ $this->install_http_router(
+ array(
+ '/sites/site-123/products' => $body,
+ '/sites/site-123/collections' => MockWebflowData::collections_list_response_body(),
+ '/collections/coll-cats/items' => MockWebflowData::categories_collection_items_response_body(),
+ )
+ );
+
+ $result = $this->fetcher->fetch_batch( array( 'limit' => 1 ) );
+
+ $this->assertFalse( $result['has_next_page'] );
+ $this->assertNull( $result['cursor'] );
+ }
+
+ /**
+ * Test that fetch_total_count returns pagination.total.
+ */
+ public function test_fetch_total_count(): void {
+ $this->install_http_router(
+ array(
+ '/sites/site-123/products' => MockWebflowData::products_list_response_body(),
+ )
+ );
+
+ $this->assertSame( 7, $this->fetcher->fetch_total_count( array() ) );
+ }
+
+ /**
+ * Test that fetched items get `_resolved_categories` attached when the site has a categories collection.
+ */
+ public function test_items_get_resolved_categories_attached(): void {
+ $this->install_http_router(
+ array(
+ '/sites/site-123/products' => MockWebflowData::products_list_response_body(),
+ '/sites/site-123/collections' => MockWebflowData::collections_list_response_body(),
+ '/collections/coll-cats/items' => MockWebflowData::categories_collection_items_response_body(),
+ )
+ );
+
+ $result = $this->fetcher->fetch_batch( array( 'limit' => 2 ) );
+
+ $first_item = $result['items'][0];
+ $this->assertObjectHasProperty( '_resolved_categories', $first_item );
+ $this->assertNotEmpty( $first_item->_resolved_categories );
+
+ $names = array_map(
+ static function ( $entry ) {
+ return $entry['name'];
+ },
+ $first_item->_resolved_categories
+ );
+ $this->assertContains( 'Shirts', $names );
+ }
+
+ /**
+ * Category resolution is loaded once and reused across batches (guards against N+1).
+ */
+ public function test_category_resolution_is_cached_across_batches(): void {
+ $counts = array(
+ 'products' => 0,
+ 'collections' => 0,
+ 'category_items' => 0,
+ );
+
+ $cb = function ( $preempt, $args, $url ) use ( &$counts ) {
+ unset( $preempt, $args );
+ if ( false !== strpos( $url, '/collections/coll-cats/items' ) ) {
+ ++$counts['category_items'];
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => MockWebflowData::categories_collection_items_response_body(),
+ );
+ }
+ if ( false !== strpos( $url, '/collections' ) ) {
+ ++$counts['collections'];
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => MockWebflowData::collections_list_response_body(),
+ );
+ }
+ if ( false !== strpos( $url, '/products' ) ) {
+ ++$counts['products'];
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => MockWebflowData::products_list_response_body(),
+ );
+ }
+ return array(
+ 'response' => array( 'code' => 404 ),
+ 'body' => wp_json_encode( array( 'message' => 'Not stubbed: ' . $url ) ),
+ );
+ };
+ add_filter( 'pre_http_request', $cb, 10, 3 );
+ $this->http_filters[] = $cb;
+
+ $this->fetcher->fetch_batch( array( 'limit' => 2 ) );
+ $this->fetcher->fetch_batch(
+ array(
+ 'limit' => 2,
+ 'after_cursor' => '2',
+ )
+ );
+
+ $this->assertSame( 2, $counts['products'], 'Products are fetched once per batch.' );
+ $this->assertSame( 1, $counts['collections'], 'Collections list is resolved once and cached across batches.' );
+ $this->assertSame( 1, $counts['category_items'], 'Category items are resolved once and cached across batches.' );
+ }
+
+ /**
+ * The category-items pagination loop fetches every page, not just the first.
+ */
+ public function test_category_items_pagination_fetches_all_pages(): void {
+ $category_items_calls = 0;
+
+ $cb = function ( $preempt, $args, $url ) use ( &$category_items_calls ) {
+ unset( $preempt, $args );
+ if ( false !== strpos( $url, '/collections/coll-cats/items' ) ) {
+ ++$category_items_calls;
+ $body = ( false !== strpos( $url, 'offset=0' ) )
+ ? MockWebflowData::categories_collection_items_page_one_body()
+ : MockWebflowData::categories_collection_items_page_two_body();
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => $body,
+ );
+ }
+ if ( false !== strpos( $url, '/collections' ) ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => MockWebflowData::collections_list_response_body(),
+ );
+ }
+ if ( false !== strpos( $url, '/products' ) ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => MockWebflowData::products_list_response_body(),
+ );
+ }
+ return array(
+ 'response' => array( 'code' => 404 ),
+ 'body' => wp_json_encode( array( 'message' => 'Not stubbed: ' . $url ) ),
+ );
+ };
+ add_filter( 'pre_http_request', $cb, 10, 3 );
+ $this->http_filters[] = $cb;
+
+ $result = $this->fetcher->fetch_batch( array( 'limit' => 2 ) );
+
+ // Both pages (offset=0 then offset=1) must be requested.
+ $this->assertSame( 2, $category_items_calls );
+
+ // 'Shirts' lives only on page two, so the simple product resolves it only if page two loaded.
+ $first_item = $result['items'][0];
+ $names = array_map(
+ static function ( $entry ) {
+ return $entry['name'];
+ },
+ $first_item->_resolved_categories
+ );
+ $this->assertContains( 'Shirts', $names );
+ }
+
+ /**
+ * Test that when no categories collection exists, items still get an empty resolved-categories list.
+ */
+ public function test_items_get_empty_categories_when_no_collection(): void {
+ $this->install_http_router(
+ array(
+ '/sites/site-123/products' => MockWebflowData::products_list_response_body(),
+ '/sites/site-123/collections' => wp_json_encode( array( 'collections' => array() ) ),
+ )
+ );
+
+ $result = $this->fetcher->fetch_batch( array( 'limit' => 2 ) );
+
+ $first_item = $result['items'][0];
+ $this->assertObjectHasProperty( '_resolved_categories', $first_item );
+ $this->assertSame( array(), $first_item->_resolved_categories );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowMapperTest.php
new file mode 100644
index 00000000000..d219690baed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowMapperTest.php
@@ -0,0 +1,402 @@
+<?php
+/**
+ * Webflow Mapper Test
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow;
+
+use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformMapperInterface;
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowMapper;
+use Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow\Fixtures\MockWebflowData;
+
+require_once __DIR__ . '/Fixtures/MockWebflowData.php';
+
+/**
+ * Tests for WebflowMapper.
+ */
+class WebflowMapperTest extends \WC_Unit_Test_Case {
+
+ /**
+ * Mapper under test.
+ *
+ * @var WebflowMapper
+ */
+ private WebflowMapper $mapper;
+
+ /**
+ * Set up before each test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->mapper = new WebflowMapper();
+ }
+
+ /**
+ * Test the mapper implements the platform mapper interface.
+ */
+ public function test_implements_platform_mapper_interface(): void {
+ $this->assertInstanceOf( PlatformMapperInterface::class, $this->mapper );
+ }
+
+ /**
+ * Test basic field mapping for a simple product.
+ */
+ public function test_simple_product_basic_fields(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::simple_product_item() );
+
+ $this->assertSame( 'Plain Tee', $result['name'] );
+ $this->assertSame( 'plain-tee', $result['slug'] );
+ $this->assertStringContainsString( 'Just a plain tee', $result['description'] );
+ $this->assertSame( 'A tee.', $result['short_description'] );
+ $this->assertSame( 'publish', $result['status'] );
+ $this->assertSame( 'prod-simple-1', $result['original_product_id'] );
+ $this->assertSame( '2024-01-01T00:00:00Z', $result['date_created_gmt'] );
+ $this->assertFalse( $result['is_variable'] );
+ }
+
+ /**
+ * Test price mapping divides minor units by 100 and detects sale price.
+ */
+ public function test_simple_product_prices(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::simple_product_item() );
+
+ $this->assertSame( '24.99', $result['regular_price'], 'compare-at-price greater than price becomes regular_price.' );
+ $this->assertSame( '19.99', $result['sale_price'], 'price becomes sale_price when discounted.' );
+ }
+
+ /**
+ * Test that a zero price maps to '0.00' instead of being discarded.
+ */
+ public function test_zero_price_maps_to_zero_regular_price(): void {
+ $item = MockWebflowData::simple_product_item();
+
+ $item->skus[0]->fieldData->price = (object) array(
+ 'value' => 0,
+ 'unit' => 'USD',
+ );
+ $item->skus[0]->fieldData->{'compare-at-price'} = null;
+
+ $result = $this->mapper->map_product_data( $item );
+
+ $this->assertSame( '0.00', $result['regular_price'] );
+ $this->assertNull( $result['sale_price'] );
+ }
+
+ /**
+ * Zero-decimal currencies (e.g. JPY) are not divided by 100.
+ */
+ public function test_zero_decimal_currency_price_is_not_divided(): void {
+ $item = MockWebflowData::simple_product_item();
+
+ $item->skus[0]->fieldData->price = (object) array(
+ 'value' => 2000,
+ 'unit' => 'JPY',
+ );
+ $item->skus[0]->fieldData->{'compare-at-price'} = null;
+
+ $result = $this->mapper->map_product_data( $item );
+
+ $this->assertSame( '2000', $result['regular_price'] );
+ }
+
+ /**
+ * Three-decimal currencies (e.g. KWD) divide minor units by 1000.
+ */
+ public function test_three_decimal_currency_price_is_scaled_by_thousand(): void {
+ $item = MockWebflowData::simple_product_item();
+
+ $item->skus[0]->fieldData->price = (object) array(
+ 'value' => 2000,
+ 'unit' => 'KWD',
+ );
+ $item->skus[0]->fieldData->{'compare-at-price'} = null;
+
+ $result = $this->mapper->map_product_data( $item );
+
+ $this->assertSame( '2.000', $result['regular_price'] );
+ }
+
+ /**
+ * sku-properties present but only one SKU maps to a simple product (lone option dropped).
+ */
+ public function test_single_sku_with_properties_is_simple(): void {
+ $item = MockWebflowData::variable_product_item();
+ $item->skus = array( $item->skus[0] );
+
+ $result = $this->mapper->map_product_data( $item );
+
+ $this->assertFalse( $result['is_variable'] );
+ $this->assertSame( array(), $result['attributes'] );
+ $this->assertSame( array(), $result['variations'] );
+ }
+
+ /**
+ * Multiple SKUs but no sku-properties maps to a simple product.
+ */
+ public function test_multiple_skus_without_properties_is_simple(): void {
+ $item = MockWebflowData::variable_product_item();
+ unset( $item->product->fieldData->{'sku-properties'} );
+
+ $result = $this->mapper->map_product_data( $item );
+
+ $this->assertFalse( $result['is_variable'] );
+ $this->assertSame( array(), $result['attributes'] );
+ }
+
+ /**
+ * Test stock mapping for finite inventory.
+ */
+ public function test_simple_product_stock(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::simple_product_item() );
+
+ $this->assertTrue( $result['manage_stock'] );
+ $this->assertSame( 7, $result['stock_quantity'] );
+ $this->assertSame( 'instock', $result['stock_status'] );
+ }
+
+ /**
+ * Test SKU passes through and weight is converted to the store unit.
+ */
+ public function test_simple_product_sku_and_weight(): void {
+ $original_unit = get_option( 'woocommerce_weight_unit' );
+ update_option( 'woocommerce_weight_unit', 'kg' );
+
+ try {
+ $result = $this->mapper->map_product_data( MockWebflowData::simple_product_item() );
+
+ $this->assertSame( 'PLAIN-001', $result['sku'] );
+
+ // Fixture weight is 0.5 lb; converting to the store's kg unit pins the conversion
+ // (0.5 lb ≈ 0.2268 kg) rather than accepting any positive float.
+ $this->assertEqualsWithDelta( 0.2268, $result['weight'], 0.0005 );
+ } finally {
+ update_option( 'woocommerce_weight_unit', $original_unit );
+ }
+ }
+
+ /**
+ * Test simple product dimensions pass through.
+ */
+ public function test_simple_product_dimensions(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::simple_product_item() );
+
+ $this->assertSame( 10.0, $result['length'] );
+ $this->assertSame( 5.0, $result['width'] );
+ $this->assertSame( 2.0, $result['height'] );
+ }
+
+ /**
+ * Test that variation dimensions pass through and that null/missing dimensions stay null.
+ */
+ public function test_variable_product_variation_dimensions(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $by_sku = array();
+ foreach ( $result['variations'] as $variation ) {
+ $by_sku[ $variation['sku'] ] = $variation;
+ }
+
+ // Red/S has dimensions in the fixture.
+ $this->assertSame( 20.0, $by_sku['HOOD-RED-S']['length'] );
+ $this->assertSame( 15.0, $by_sku['HOOD-RED-S']['width'] );
+ $this->assertSame( 8.0, $by_sku['HOOD-RED-S']['height'] );
+
+ // Other variants in the fixture omit dimensions — should stay null.
+ $this->assertNull( $by_sku['HOOD-RED-M']['length'] );
+ $this->assertNull( $by_sku['HOOD-BLUE-S']['width'] );
+ $this->assertNull( $by_sku['HOOD-BLUE-M']['height'] );
+ }
+
+ /**
+ * Test SEO fields land in metafields.
+ */
+ public function test_simple_product_seo_metafields(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::simple_product_item() );
+
+ $this->assertArrayHasKey( 'metafields', $result );
+ $this->assertArrayHasKey( 'global_title_tag', $result['metafields'] );
+ $this->assertArrayHasKey( 'global_description_tag', $result['metafields'] );
+ $this->assertSame( 'Plain Tee — Buy Now', $result['metafields']['global_title_tag'] );
+ }
+
+ /**
+ * Test pre-resolved categories are propagated.
+ */
+ public function test_simple_product_categories(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::simple_product_item() );
+
+ $this->assertCount( 1, $result['categories'] );
+ $this->assertSame( 'Shirts', $result['categories'][0]['name'] );
+ $this->assertSame( 'shirts', $result['categories'][0]['slug'] );
+ }
+
+ /**
+ * Archived products map to draft status.
+ */
+ public function test_archived_product_maps_to_draft(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::archived_product_item() );
+
+ $this->assertSame( 'draft', $result['status'] );
+ }
+
+ /**
+ * Variable products are detected when multiple SKUs and sku-properties are present.
+ */
+ public function test_variable_product_is_variable(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $this->assertTrue( $result['is_variable'] );
+ }
+
+ /**
+ * Variable product attributes use property names with all enum names as options.
+ */
+ public function test_variable_product_attributes(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $this->assertCount( 2, $result['attributes'] );
+
+ $by_name = array();
+ foreach ( $result['attributes'] as $attr ) {
+ $by_name[ $attr['name'] ] = $attr;
+ }
+
+ $this->assertArrayHasKey( 'Color', $by_name );
+ $this->assertArrayHasKey( 'Size', $by_name );
+ $this->assertSame( array( 'Red', 'Blue' ), $by_name['Color']['options'] );
+ $this->assertSame( array( 'S', 'M' ), $by_name['Size']['options'] );
+ $this->assertTrue( $by_name['Color']['is_variation'] );
+ }
+
+ /**
+ * Variable product variations resolve sku-values to attribute_name => option_name pairs.
+ */
+ public function test_variable_product_variation_attributes_are_resolved(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $this->assertCount( 4, $result['variations'] );
+
+ $by_sku = array();
+ foreach ( $result['variations'] as $variation ) {
+ $by_sku[ $variation['sku'] ] = $variation;
+ }
+
+ $this->assertSame( 'Red', $by_sku['HOOD-RED-S']['attributes']['Color'] );
+ $this->assertSame( 'S', $by_sku['HOOD-RED-S']['attributes']['Size'] );
+ $this->assertSame( 'Blue', $by_sku['HOOD-BLUE-M']['attributes']['Color'] );
+ $this->assertSame( 'M', $by_sku['HOOD-BLUE-M']['attributes']['Size'] );
+ }
+
+ /**
+ * Variation with infinite inventory does not manage stock.
+ */
+ public function test_variable_product_infinite_inventory_variation(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $by_sku = array();
+ foreach ( $result['variations'] as $variation ) {
+ $by_sku[ $variation['sku'] ] = $variation;
+ }
+
+ $this->assertFalse( $by_sku['HOOD-RED-M']['manage_stock'] );
+ $this->assertNull( $by_sku['HOOD-RED-M']['stock_quantity'] );
+ $this->assertSame( 'instock', $by_sku['HOOD-RED-M']['stock_status'] );
+ }
+
+ /**
+ * Variation with finite inventory at 0 is outofstock.
+ */
+ public function test_variable_product_zero_inventory_variation_is_outofstock(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $by_sku = array();
+ foreach ( $result['variations'] as $variation ) {
+ $by_sku[ $variation['sku'] ] = $variation;
+ }
+
+ $this->assertTrue( $by_sku['HOOD-BLUE-S']['manage_stock'] );
+ $this->assertSame( 0, $by_sku['HOOD-BLUE-S']['stock_quantity'] );
+ $this->assertSame( 'outofstock', $by_sku['HOOD-BLUE-S']['stock_status'] );
+ }
+
+ /**
+ * Sale price detection on a variation (compare-at-price > price).
+ */
+ public function test_variable_product_variation_sale_price(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $by_sku = array();
+ foreach ( $result['variations'] as $variation ) {
+ $by_sku[ $variation['sku'] ] = $variation;
+ }
+
+ $this->assertSame( '59.99', $by_sku['HOOD-BLUE-S']['regular_price'] );
+ $this->assertSame( '54.99', $by_sku['HOOD-BLUE-S']['sale_price'] );
+ }
+
+ /**
+ * Images: product more-images + SKU main-images all appear in product.images[] and are deduped.
+ */
+ public function test_images_deduped_across_product_and_variants(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $this->assertIsArray( $result['images'] );
+
+ $urls = array_column( $result['images'], 'src' );
+ $this->assertContains( 'https://cdn.webflow.test/gallery.jpg', $urls );
+ $this->assertContains( 'https://cdn.webflow.test/red.jpg', $urls );
+ $this->assertContains( 'https://cdn.webflow.test/blue.jpg', $urls );
+ $this->assertSame( count( $urls ), count( array_unique( $urls ) ), 'Image URLs should be deduplicated.' );
+ }
+
+ /**
+ * Each variation's image_original_id keys into the product's images[].original_id.
+ */
+ public function test_variation_image_keys_into_images_array(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $image_ids = array_column( $result['images'], 'original_id' );
+
+ foreach ( $result['variations'] as $variation ) {
+ $this->assertNotNull( $variation['image_original_id'], "Variation {$variation['sku']} should reference an image." );
+ $this->assertContains( $variation['image_original_id'], $image_ids, "Variation {$variation['sku']} image_original_id must exist in images[]." );
+ }
+ }
+
+ /**
+ * Exactly one image is marked is_featured.
+ */
+ public function test_one_image_is_featured(): void {
+ $result = $this->mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $featured_count = 0;
+ foreach ( $result['images'] as $image ) {
+ if ( ! empty( $image['is_featured'] ) ) {
+ ++$featured_count;
+ }
+ }
+ $this->assertSame( 1, $featured_count );
+ }
+
+ /**
+ * Field selection: when 'images' is excluded, images and variation image refs are empty.
+ */
+ public function test_excluding_images_field(): void {
+ $mapper = new WebflowMapper(
+ array(
+ 'fields' => array( 'name', 'slug', 'price', 'sku', 'stock', 'attributes' ),
+ )
+ );
+
+ $result = $mapper->map_product_data( MockWebflowData::variable_product_item() );
+
+ $this->assertSame( array(), $result['images'] );
+ foreach ( $result['variations'] as $variation ) {
+ $this->assertNull( $variation['image_original_id'] );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowPlatformTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowPlatformTest.php
new file mode 100644
index 00000000000..f78fbb70087
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowPlatformTest.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Webflow Platform Test
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow;
+
+use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry;
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowFetcher;
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowMapper;
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowPlatform;
+
+/**
+ * Test cases for Webflow platform registration.
+ */
+class WebflowPlatformTest extends \WC_Unit_Test_Case {
+
+ /**
+ * Set up each test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ remove_all_filters( 'woocommerce_migrator_platforms' );
+ WebflowPlatform::init();
+ }
+
+ /**
+ * Clean up after each test.
+ */
+ public function tearDown(): void {
+ remove_all_filters( 'woocommerce_migrator_platforms' );
+ parent::tearDown();
+ }
+
+ /**
+ * Test that the Webflow platform is registered with the expected configuration.
+ */
+ public function test_webflow_platform_is_registered() {
+ $registry = new PlatformRegistry();
+ $platforms = $registry->get_platforms();
+
+ $this->assertArrayHasKey( 'webflow', $platforms );
+ $this->assertSame( 'Webflow', $platforms['webflow']['name'] );
+ $this->assertSame( WebflowFetcher::class, $platforms['webflow']['fetcher'] );
+ $this->assertSame( WebflowMapper::class, $platforms['webflow']['mapper'] );
+ }
+
+ /**
+ * Test that the credentials prompt set includes site_id and access_token.
+ */
+ public function test_credentials_include_site_id_and_access_token() {
+ $registry = new PlatformRegistry();
+ $fields = $registry->get_platform_credential_fields( 'webflow' );
+
+ $this->assertArrayHasKey( 'site_id', $fields );
+ $this->assertArrayHasKey( 'access_token', $fields );
+ }
+
+ /**
+ * Test that multiple init() calls don't cause duplicate registrations.
+ */
+ public function test_multiple_init_calls_safe() {
+ WebflowPlatform::init();
+ WebflowPlatform::init();
+
+ $registry = new PlatformRegistry();
+ $platforms = $registry->get_platforms();
+
+ $this->assertCount( 1, $platforms );
+ $this->assertArrayHasKey( 'webflow', $platforms );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowRealDataIntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowRealDataIntegrationTest.php
new file mode 100644
index 00000000000..bf4c438bcf2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/WebflowRealDataIntegrationTest.php
@@ -0,0 +1,354 @@
+<?php
+/**
+ * Webflow End-to-End Pipeline Test
+ *
+ * Drives WebflowFetcher and WebflowMapper together against a Webflow-shaped
+ * HTTP response, with the network layer stubbed via `pre_http_request`. This
+ * is the closest thing to a `wp wc migrate products --dry-run` we can run
+ * without booting wp-env, and it's the test that guards the contract
+ * between the two classes — specifically that the fetcher resolves the
+ * categories collection once and decorates each item with the resolved
+ * `_resolved_categories` that the mapper then surfaces as `categories[]`.
+ *
+ * Individual mapper edge cases (null main-image, compare-at-price, infinite
+ * inventory, etc.) are covered by WebflowMapperTest. This test focuses on
+ * what only an end-to-end run exercises.
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow;
+
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowFetcher;
+use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow\WebflowMapper;
+use WC_Unit_Test_Case;
+
+/**
+ * End-to-end fetcher → mapper test against a Webflow-shaped response.
+ */
+class WebflowRealDataIntegrationTest extends WC_Unit_Test_Case {
+
+ private const SITE_ID = 'site-test';
+
+ /**
+ * Set up the HTTP stub for each test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ add_filter( 'pre_http_request', array( $this, 'handle_request' ), 10, 3 );
+ }
+
+ /**
+ * Tear down the HTTP stub.
+ */
+ public function tearDown(): void {
+ remove_filter( 'pre_http_request', array( $this, 'handle_request' ), 10 );
+ parent::tearDown();
+ }
+
+ /**
+ * Route Webflow API requests to canned responses. The fetcher hits three endpoints:
+ * - /sites/{id}/products → product list
+ * - /sites/{id}/collections → discovers the Categories collection
+ * - /collections/{id}/items → loads category items for resolution
+ *
+ * @param mixed $preempt Always ignored (we always preempt).
+ * @param array $args Request args.
+ * @param string $url Request URL.
+ * @return array
+ */
+ public function handle_request( $preempt, $args, $url ): array {
+ unset( $preempt, $args );
+
+ if ( false !== strpos( $url, '/sites/' . self::SITE_ID . '/products' ) ) {
+ return $this->ok( $this->products_response() );
+ }
+ if ( false !== strpos( $url, '/sites/' . self::SITE_ID . '/collections' ) ) {
+ return $this->ok( $this->collections_response() );
+ }
+ if ( false !== strpos( $url, '/collections/coll-cats/items' ) ) {
+ return $this->ok( $this->categories_response() );
+ }
+
+ return array(
+ 'response' => array( 'code' => 404 ),
+ 'body' => wp_json_encode( array( 'message' => 'Not stubbed: ' . $url ) ),
+ );
+ }
+
+ /**
+ * 200 OK with body helper.
+ *
+ * @param string $body Response body.
+ * @return array
+ */
+ private function ok( string $body ): array {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => $body,
+ );
+ }
+
+ /**
+ * A two-product list: one simple, one variable. Mirrors Webflow v2 shape — products
+ * wrap a `product` object and a `skus` array, category is an array of CMS item IDs,
+ * prices use minor units, sku-properties is null on simple products.
+ *
+ * @return string
+ */
+ private function products_response(): string {
+ return wp_json_encode(
+ array(
+ 'items' => array(
+ array(
+ 'product' => array(
+ 'id' => 'prod-simple',
+ 'isArchived' => false,
+ 'isDraft' => false,
+ 'fieldData' => array(
+ 'name' => 'Tee',
+ 'slug' => 'tee',
+ 'description' => 'A tee.',
+ 'sku-properties' => null,
+ 'category' => array( 'cat-shirts' ),
+ ),
+ ),
+ 'skus' => array(
+ array(
+ 'id' => 'sku-tee',
+ 'fieldData' => array(
+ 'sku' => 'TEE-001',
+ 'price' => array(
+ 'unit' => 'USD',
+ 'value' => 2000,
+ ),
+ 'main-image' => null,
+ 'sku-values' => new \stdClass(),
+ ),
+ ),
+ ),
+ ),
+ array(
+ 'product' => array(
+ 'id' => 'prod-variable',
+ 'isArchived' => false,
+ 'isDraft' => false,
+ 'fieldData' => array(
+ 'name' => 'Hoodie',
+ 'slug' => 'hoodie',
+ 'description' => 'A hoodie.',
+ 'sku-properties' => array(
+ array(
+ 'id' => 'prop-color',
+ 'name' => 'Color',
+ 'enum' => array(
+ array(
+ 'id' => 'enum-red',
+ 'name' => 'Red',
+ 'slug' => 'red',
+ ),
+ array(
+ 'id' => 'enum-blue',
+ 'name' => 'Blue',
+ 'slug' => 'blue',
+ ),
+ ),
+ ),
+ ),
+ 'category' => array( 'cat-outerwear', 'cat-missing' ),
+ ),
+ ),
+ 'skus' => array(
+ array(
+ 'id' => 'sku-red',
+ 'fieldData' => array(
+ 'sku' => 'HOOD-R',
+ 'price' => array(
+ 'unit' => 'USD',
+ 'value' => 5000,
+ ),
+ 'sku-values' => array( 'prop-color' => 'enum-red' ),
+ 'main-image' => array(
+ 'fileId' => 'img-shared',
+ 'url' => 'https://cdn.example.test/hood.jpg',
+ 'alt' => null,
+ ),
+ ),
+ ),
+ array(
+ 'id' => 'sku-blue',
+ 'fieldData' => array(
+ 'sku' => 'HOOD-B',
+ 'price' => array(
+ 'unit' => 'USD',
+ 'value' => 5000,
+ ),
+ 'sku-values' => array( 'prop-color' => 'enum-blue' ),
+ 'main-image' => array(
+ 'fileId' => 'img-shared',
+ 'url' => 'https://cdn.example.test/hood.jpg',
+ 'alt' => null,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ 'pagination' => array(
+ 'limit' => 100,
+ 'offset' => 0,
+ 'total' => 2,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Collections response: the Categories collection plus an unrelated one, so the
+ * fetcher must discriminate by slug.
+ *
+ * @return string
+ */
+ private function collections_response(): string {
+ return wp_json_encode(
+ array(
+ 'collections' => array(
+ array(
+ 'id' => 'coll-blog',
+ 'slug' => 'post',
+ 'displayName' => 'Blog Posts',
+ ),
+ array(
+ 'id' => 'coll-cats',
+ 'slug' => 'category',
+ 'displayName' => 'Categories',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Categories collection items. Note: `cat-missing` is intentionally absent here
+ * so we can assert the fetcher silently drops unresolved IDs (matches real
+ * archived-category behavior).
+ *
+ * @return string
+ */
+ private function categories_response(): string {
+ return wp_json_encode(
+ array(
+ 'items' => array(
+ array(
+ 'id' => 'cat-shirts',
+ 'fieldData' => array(
+ 'name' => 'Shirts',
+ 'slug' => 'shirts',
+ ),
+ ),
+ array(
+ 'id' => 'cat-outerwear',
+ 'fieldData' => array(
+ 'name' => 'Outerwear',
+ 'slug' => 'outerwear',
+ ),
+ ),
+ ),
+ 'pagination' => array(
+ 'limit' => 100,
+ 'offset' => 0,
+ 'total' => 2,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Drive the full pipeline and return mapped products keyed by name.
+ *
+ * @return array<string,array>
+ */
+ private function run_pipeline(): array {
+ $fetcher = new WebflowFetcher(
+ array(
+ 'site_id' => self::SITE_ID,
+ 'access_token' => 'fake-token',
+ )
+ );
+
+ $batch = $fetcher->fetch_batch( array( 'limit' => 100 ) );
+ $mapper = new WebflowMapper();
+
+ $by_name = array();
+ foreach ( $batch['items'] as $item ) {
+ $mapped = $mapper->map_product_data( $item );
+ $by_name[ $mapped['name'] ] = $mapped;
+ }
+ return $by_name;
+ }
+
+ /**
+ * Test that the fetcher resolves the Categories collection and the mapper surfaces it.
+ *
+ * This is the load-bearing assertion: without the fetcher's `_resolved_categories`
+ * decoration, the mapper would have nothing to read and `categories[]` would be empty.
+ */
+ public function test_categories_flow_from_fetcher_through_mapper(): void {
+ $mapped = $this->run_pipeline();
+
+ $this->assertSame(
+ array( 'Shirts' ),
+ array_column( $mapped['Tee']['categories'], 'name' )
+ );
+
+ // "Hoodie" references both an existing (cat-outerwear) and a missing (cat-missing)
+ // category — only the resolved one should appear.
+ $this->assertSame(
+ array( 'Outerwear' ),
+ array_column( $mapped['Hoodie']['categories'], 'name' )
+ );
+ }
+
+ /**
+ * Test that variation attributes resolve through sku-properties end-to-end.
+ *
+ * Webflow's `sku-values` map property IDs → enum IDs; the mapper has to traverse the
+ * property catalog (which lives on `product.fieldData['sku-properties']`) to produce
+ * `attribute_name => option_name`. This wires together the fetcher payload shape and
+ * the mapper's resolution logic.
+ */
+ public function test_variation_attributes_resolve_end_to_end(): void {
+ $mapped = $this->run_pipeline();
+
+ $this->assertTrue( $mapped['Hoodie']['is_variable'] );
+ $this->assertCount( 2, $mapped['Hoodie']['variations'] );
+
+ $colours = array();
+ foreach ( $mapped['Hoodie']['variations'] as $variation ) {
+ $colours[] = $variation['attributes']['Color'] ?? null;
+ }
+ $this->assertEqualsCanonicalizing( array( 'Red', 'Blue' ), $colours );
+ }
+
+ /**
+ * Test that variations sharing one image URL produce one images[] entry that every
+ * variation keys into via image_original_id.
+ *
+ * This is the importer contract the assessment doc calls out: WooCommerceProductImporter
+ * builds an `original_id => attachment_id` map from `images[]`, and variations resolve
+ * their image via that map. If a variation references an `image_original_id` not in
+ * `images[]`, the importer can't set the variation's image.
+ */
+ public function test_shared_variant_image_is_deduped_and_referenced(): void {
+ $mapped = $this->run_pipeline();
+
+ $this->assertCount( 1, $mapped['Hoodie']['images'] );
+ $image_id = $mapped['Hoodie']['images'][0]['original_id'];
+
+ foreach ( $mapped['Hoodie']['variations'] as $variation ) {
+ $this->assertSame( $image_id, $variation['image_original_id'] );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/functions-mock.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/functions-mock.php
new file mode 100644
index 00000000000..71bb76458d6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Webflow/functions-mock.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Test function shadows for the Webflow platform namespace.
+ *
+ * Defining sleep() inside the WebflowClient namespace lets the 429 retry/backoff
+ * tests run instantly. PHP resolves the unqualified sleep() call in WebflowClient
+ * to this namespaced function before falling back to the global one, so production
+ * code stays untouched and no sleep dependency has to be injected.
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\CLI\Migrator\Platforms\Webflow
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow;
+
+if ( ! function_exists( __NAMESPACE__ . '\\sleep' ) ) {
+ /**
+ * No-op replacement for the global sleep() within this namespace so retry/backoff
+ * tests do not actually wait.
+ *
+ * @param int $seconds Number of seconds (ignored).
+ * @return int Always 0, matching a completed sleep.
+ */
+ function sleep( $seconds ) { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing, WordPress.NamingConventions
+ unset( $seconds );
+ return 0;
+ }
+}