From e2890db231ddbc51ca2c77f854e5efed62d01a74 Mon Sep 17 00:00:00 2001 From: Jeffrey van Rossum Date: Sat, 29 Aug 2026 17:18:07 +0900 Subject: [PATCH] Send a thumbnail on every sync, including null to clear one. Omitting the key left a deleted featured image stored in Datalumo. Always send the field, and still fill Woo products from the product image when the post has none. Co-authored-by: Cursor --- src/Integration/WooCommerce.php | 36 ++++++++++++++++++- src/Sync/PagePreparer.php | 23 ++++++++++++ tests/Unit/PagePreparerTest.php | 31 +++++++++++++++- tests/Unit/WooCommerceSkuTest.php | 59 ++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/Integration/WooCommerce.php b/src/Integration/WooCommerce.php index 930b18c..abdc6f7 100644 --- a/src/Integration/WooCommerce.php +++ b/src/Integration/WooCommerce.php @@ -52,7 +52,9 @@ public function enrichProduct(array $payload, WP_Post $post): array $payload = $this->attachTaxonomies($payload, $post); $payload = $this->attachAttributes($payload, $product); - return $this->attachSku($payload, $product); + $payload = $this->attachSku($payload, $product); + + return $this->attachThumbnail($payload, $product); } /** @@ -182,6 +184,38 @@ private function attachSku(array $payload, object $product): array return $this->appendSearchableLines($payload, ['sku: '.implode(', ', $skus)]); } + /** + * Product image when the post has no featured image of its own + * (variations often inherit the parent's). + * + * @param array $payload + * @return array + */ + private function attachThumbnail(array $payload, object $product): array + { + if (! empty($payload['thumbnail']) || ! method_exists($product, 'get_image_id')) { + return $payload; + } + + $imageId = (int) $product->get_image_id(); + + if ($imageId <= 0) { + return $payload; + } + + $url = wp_get_attachment_image_url($imageId, 'large'); + + if (is_string($url) && $url !== '') { + if (str_starts_with(strtolower($url), 'http://')) { + $url = 'https://'.substr($url, 7); + } + + $payload['thumbnail'] = $url; + } + + return $payload; + } + /** * @return array */ diff --git a/src/Sync/PagePreparer.php b/src/Sync/PagePreparer.php index b42e30d..cdd1dbf 100644 --- a/src/Sync/PagePreparer.php +++ b/src/Sync/PagePreparer.php @@ -66,6 +66,8 @@ public function prepare(WP_Post $post, array $metaMappings = []): ?array 'meta' => array_filter($meta, fn ($value) => $value !== null && $value !== '' && $value !== []), ]; + $payload['thumbnail'] = $this->featuredImageUrl($post); + $payload = apply_filters('datalumo_page_payload', $payload, $post); if (! is_array($payload)) { @@ -126,6 +128,27 @@ private function ensureWooCommerceNotices(): void } } + /** + * Featured image as an https URL, or null when the post has none + * (null is sent so a re-sync can clear a stored URL). `large` keeps + * search-card payloads smaller than the original. http is upgraded + * so HTTPS dashboards do not block the image. + */ + private function featuredImageUrl(WP_Post $post): ?string + { + $url = get_the_post_thumbnail_url($post, 'large'); + + if (! is_string($url) || $url === '') { + return null; + } + + if (str_starts_with(strtolower($url), 'http://')) { + $url = 'https://'.substr($url, 7); + } + + return str_starts_with(strtolower($url), 'https://') ? $url : null; + } + /** * @return array */ diff --git a/tests/Unit/PagePreparerTest.php b/tests/Unit/PagePreparerTest.php index b413063..beba231 100644 --- a/tests/Unit/PagePreparerTest.php +++ b/tests/Unit/PagePreparerTest.php @@ -22,6 +22,7 @@ Functions\when('get_post_time')->justReturn('2004-01-01T00:00:00+00:00'); Functions\when('get_post_modified_time')->justReturn('2004-01-02T00:00:00+00:00'); Functions\when('get_permalink')->justReturn('https://example.com/post'); + Functions\when('get_the_post_thumbnail_url')->justReturn(false); }); it('decodes HTML entities in the post title', function () { @@ -30,7 +31,35 @@ $payload = (new PagePreparer())->prepare(new WP_Post()); expect($payload['name'])->toBe('Kleine’ Douglas redde het niet') - ->and($payload['content_mime'])->toBe('text/html'); + ->and($payload['content_mime'])->toBe('text/html') + ->and($payload['thumbnail'])->toBeNull(); +}); + +it('upgrades an http featured image to https', function () { + Functions\when('get_the_title')->justReturn('Tote'); + Functions\when('get_the_post_thumbnail_url')->justReturn('http://datalumo-fresh.test/wp-content/uploads/tote.png'); + + $payload = (new PagePreparer())->prepare(new WP_Post()); + + expect($payload['thumbnail'])->toBe('https://datalumo-fresh.test/wp-content/uploads/tote.png'); +}); + +it('includes the featured image as a thumbnail', function () { + Functions\when('get_the_title')->justReturn('Guide'); + Functions\when('get_the_post_thumbnail_url')->justReturn('https://example.com/wp-content/uploads/guide-1024x768.jpg'); + + $payload = (new PagePreparer())->prepare(new WP_Post()); + + expect($payload['thumbnail'])->toBe('https://example.com/wp-content/uploads/guide-1024x768.jpg'); +}); + +it('skips a non-http featured image', function () { + Functions\when('get_the_title')->justReturn('Guide'); + Functions\when('get_the_post_thumbnail_url')->justReturn('javascript:alert(1)'); + + $payload = (new PagePreparer())->prepare(new WP_Post()); + + expect($payload['thumbnail'])->toBeNull(); }); it('falls back when block rendering fatals', function () { diff --git a/tests/Unit/WooCommerceSkuTest.php b/tests/Unit/WooCommerceSkuTest.php index cad9da3..9a602de 100644 --- a/tests/Unit/WooCommerceSkuTest.php +++ b/tests/Unit/WooCommerceSkuTest.php @@ -10,8 +10,9 @@ function wcProduct( string $shortDescription = '', array $attributes = [], int $id = 1, + int $imageId = 0, ): object { - return new class($sku, $type, $children, $shortDescription, $attributes, $id) + return new class($sku, $type, $children, $shortDescription, $attributes, $id, $imageId) { public function __construct( private string $sku, @@ -20,6 +21,7 @@ public function __construct( private string $shortDescription, private array $attributes, private int $id, + private int $imageId, ) {} public function get_id(): int @@ -51,6 +53,11 @@ public function get_children(): array { return $this->children; } + + public function get_image_id(): int + { + return $this->imageId; + } }; } @@ -228,3 +235,53 @@ public function get_options(): array ->and($payload['content'])->toContain('Color: Blue, Red') ->and($payload['content'])->not->toContain('Internal'); }); + +it('adds a product image when the thumbnail is null', function () { + Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'MUG-1', imageId: 44)); + Functions\when('wp_get_attachment_image_url')->justReturn('http://shop.example/mug-large.jpg'); + + $post = new WP_Post(); + $post->ID = 4; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

A mug.

', + 'meta' => ['post_type' => 'product'], + 'thumbnail' => null, + ], $post); + + expect($payload['thumbnail'])->toBe('https://shop.example/mug-large.jpg'); +}); + +it('adds a product image when the payload has no thumbnail', function () { + Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'MUG-1', imageId: 44)); + Functions\when('wp_get_attachment_image_url')->justReturn('http://shop.example/mug-large.jpg'); + + $post = new WP_Post(); + $post->ID = 4; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

A mug.

', + 'meta' => ['post_type' => 'product'], + ], $post); + + expect($payload['thumbnail'])->toBe('https://shop.example/mug-large.jpg'); +}); + +it('does not overwrite an existing thumbnail', function () { + Functions\when('wc_get_product')->justReturn(wcProduct(sku: 'MUG-1', imageId: 44)); + Functions\when('wp_get_attachment_image_url')->justReturn('https://shop.example/mug-large.jpg'); + + $post = new WP_Post(); + $post->ID = 4; + $post->post_type = 'product'; + + $payload = (new WooCommerce())->enrichProduct([ + 'content' => '

A mug.

', + 'meta' => ['post_type' => 'product'], + 'thumbnail' => 'https://example.com/already.jpg', + ], $post); + + expect($payload['thumbnail'])->toBe('https://example.com/already.jpg'); +});