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;
+	}
+}