From 592e4ff292c595cb469b7150aa8d188a438be77b Mon Sep 17 00:00:00 2001 From: GantasG Date: Tue, 18 Aug 2026 16:20:14 +0300 Subject: [PATCH 01/12] feat(prestashop): map merchant custom product fields into additionalFields --- src/Adapters/PrestaShopAdapterV2.php | 51 ++++++++++++++ tests/Adapters/PrestaShopAdapterV2Test.php | 79 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 27d0034..9a7afff 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -145,6 +145,8 @@ public function transformProduct(array $product): Product // Handle features $this->transformFeatures($additionalFields, (array) ($product['features'] ?? [])); + $this->transformCustomFields($additionalFields, (array) ($product['customFields'] ?? [])); + // Handle tags $this->transformTags($additionalFields, $product['tags'] ?? []); @@ -588,6 +590,55 @@ private function transformFeatures(array &$result, array $features): void } } + /** + * Transform merchant-selected custom product columns to flat prefixed fields. + * + * The `custom_` prefix is mandatory: Product::fromArray() treats id/sku/price/ + * basePrice/priceTaxExcluded/basePriceTaxExcluded/imageUrl/inStock/isNew as core + * fields, so an unprefixed merchant column named `price` would be swallowed and + * one named `id` would corrupt product identity. + * + * Every locale is suffixed, including the first — PrestaShopAdapterV2 takes no + * locale list and has no notion of a default locale, unlike the Shopify and + * Magento adapters. + * + * @param array $result + * @param array $customFields + */ + private function transformCustomFields(array &$result, array $customFields): void + { + foreach ($customFields as $field) { + if (!is_array($field)) { + continue; + } + + $name = isset($field['name']) && is_string($field['name']) ? $field['name'] : ''; + if ($name === '') { + continue; + } + + $fieldName = 'custom_' . $name; + + if (isset($field['localizedValues']) && is_array($field['localizedValues'])) { + foreach ($field['localizedValues'] as $locale => $value) { + if (!is_string($locale) || $locale === '' || $value === null || $value === '') { + continue; + } + + $result["{$fieldName}_{$locale}"] = $value; + } + + continue; + } + + if (!isset($field['value']) || $field['value'] === null || $field['value'] === '') { + continue; + } + + $result[$fieldName] = $field['value']; + } + } + /** * Transform tags to create localized fields. * diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index 60fad47..22aedfa 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -1129,6 +1129,85 @@ public function testTransformProductWithEmptyTimestamps(): void $this->assertArrayNotHasKey('updatedAt', $product->additionalFields); } + public function testTransformLocalizedCustomFieldSuffixesEveryLocale(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + [ + 'name' => 'internal_name', + 'type' => 'text', + 'localizedValues' => ['en-US' => 'Cotton shirt', 'lt-LT' => 'Medvilniniai'], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $product = $result['products'][0]; + + $this->assertSame('Cotton shirt', $product->additionalFields['custom_internal_name_en-US']); + $this->assertSame('Medvilniniai', $product->additionalFields['custom_internal_name_lt-LT']); + $this->assertArrayNotHasKey('custom_internal_name', $product->additionalFields); + } + + public function testTransformNonLocalizedCustomFieldHasNoLocaleSuffix(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'warehouse_slot', 'type' => 'text', 'value' => 'A-12'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + + $this->assertSame('A-12', $result['products'][0]->additionalFields['custom_warehouse_slot']); + } + + public function testCustomFieldPrefixPreventsCollisionWithCoreFields(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'price', 'type' => 'double', 'value' => '0.01'], + ['name' => 'id', 'type' => 'integer', 'value' => '999999'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $product = $result['products'][0]; + $serialized = $product->jsonSerialize(); + + $this->assertSame('0.01', $product->additionalFields['custom_price']); + $this->assertSame('999999', $product->additionalFields['custom_id']); + $this->assertSame('1807', $serialized['id']); + $this->assertSame(99.99, $serialized['price']); + } + + public function testMissingCustomFieldsKeyIsHarmless(): void + { + $result = $this->adapter->transform($this->getMinimalValidProduct()); + + $this->assertCount(0, $result['errors']); + $this->assertSame([], array_filter( + array_keys($result['products'][0]->additionalFields), + static fn (string $key): bool => str_starts_with($key, 'custom_') + )); + } + + public function testMalformedCustomFieldEntriesAreSkippedNotFatal(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + 'not-an-array', + ['type' => 'text', 'value' => 'no name key'], + ['name' => '', 'type' => 'text', 'value' => 'empty name'], + ['name' => 'internal_name', 'type' => 'text', 'localizedValues' => ['en-US' => '']], + ['name' => 'good', 'type' => 'text', 'value' => 'kept'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertSame('kept', $additional['custom_good']); + $this->assertArrayNotHasKey('custom_internal_name_en-US', $additional); + } + /** * Helper method to get minimal valid product data. * From 43271d0442cadc127a9575d211844c3151ce5449 Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 21 Aug 2026 15:15:44 +0300 Subject: [PATCH 02/12] style(prestashop): strip development comments --- src/Adapters/PrestaShopAdapterV2.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 9a7afff..1803ab4 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -591,17 +591,6 @@ private function transformFeatures(array &$result, array $features): void } /** - * Transform merchant-selected custom product columns to flat prefixed fields. - * - * The `custom_` prefix is mandatory: Product::fromArray() treats id/sku/price/ - * basePrice/priceTaxExcluded/basePriceTaxExcluded/imageUrl/inStock/isNew as core - * fields, so an unprefixed merchant column named `price` would be swallowed and - * one named `id` would corrupt product identity. - * - * Every locale is suffixed, including the first — PrestaShopAdapterV2 takes no - * locale list and has no notion of a default locale, unlike the Shopify and - * Magento adapters. - * * @param array $result * @param array $customFields */ From 7867be884880d63ab03a9e2ca430e81a04625a1c Mon Sep 17 00:00:00 2001 From: GantasG Date: Mon, 24 Aug 2026 12:56:45 +0300 Subject: [PATCH 03/12] fix(prestashop): sanitize custom field values with strip_tags transformCustomFields() inlined the localized-values loop instead of reusing addLocalizedField(), and the scalar branch never sanitized at all, so custom fields indexed raw HTML unlike every other localized field (name, description, brand). --- src/Adapters/PrestaShopAdapterV2.php | 10 ++------ tests/Adapters/PrestaShopAdapterV2Test.php | 29 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 1803ab4..19dafc8 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -609,13 +609,7 @@ private function transformCustomFields(array &$result, array $customFields): voi $fieldName = 'custom_' . $name; if (isset($field['localizedValues']) && is_array($field['localizedValues'])) { - foreach ($field['localizedValues'] as $locale => $value) { - if (!is_string($locale) || $locale === '' || $value === null || $value === '') { - continue; - } - - $result["{$fieldName}_{$locale}"] = $value; - } + $this->addLocalizedField($result, $fieldName, $field['localizedValues']); continue; } @@ -624,7 +618,7 @@ private function transformCustomFields(array &$result, array $customFields): voi continue; } - $result[$fieldName] = $field['value']; + $result[$fieldName] = strip_tags((string) $field['value']); } } diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index 22aedfa..1d321cf 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -1178,6 +1178,35 @@ public function testCustomFieldPrefixPreventsCollisionWithCoreFields(): void $this->assertSame(99.99, $serialized['price']); } + public function testTransformLocalizedCustomFieldStripsHtml(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + [ + 'name' => 'internal_name', + 'type' => 'text', + 'localizedValues' => ['en-US' => 'ALPHA-7741'], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $product = $result['products'][0]; + + $this->assertSame('ALPHA-7741', $product->additionalFields['custom_internal_name_en-US']); + } + + public function testTransformNonLocalizedCustomFieldStripsHtml(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'warehouse_slot', 'type' => 'text', 'value' => 'ALPHA-7741'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + + $this->assertSame('ALPHA-7741', $result['products'][0]->additionalFields['custom_warehouse_slot']); + } + public function testMissingCustomFieldsKeyIsHarmless(): void { $result = $this->adapter->transform($this->getMinimalValidProduct()); From d0b53006cc0971306cfbb86ca188f1342cb24088 Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 15:12:16 +0300 Subject: [PATCH 04/12] fix(customfields): skip non-scalar custom field values instead of stringifying them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom field whose value arrived as an array was cast with (string), which emits an Array to string conversion warning and stores the literal Array — silent data corruption in the index. An object value would have thrown outright, failing the whole product transform rather than one field. Guards both paths a custom field can take. The localized branch routes through addLocalizedField(), which is shared with name/description/brand/features, so the guard there also removes the same corruption path for core fields; skipping is strictly better than storing Array and no caller can want the old behaviour. Both tests fail without the guards, emitting the conversion warning. --- src/Adapters/PrestaShopAdapterV2.php | 4 +-- tests/Adapters/PrestaShopAdapterV2Test.php | 37 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 19dafc8..7e7483a 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -614,7 +614,7 @@ private function transformCustomFields(array &$result, array $customFields): voi continue; } - if (!isset($field['value']) || $field['value'] === null || $field['value'] === '') { + if (!isset($field['value']) || !is_scalar($field['value']) || $field['value'] === '') { continue; } @@ -668,7 +668,7 @@ private function addLocalizedField(array &$result, string $fieldName, array $loc foreach ($localizedValues as $locale => $value) { if ( !is_string($locale) || $locale === '' || - $value === null || $value === '' + !is_scalar($value) || $value === '' ) { continue; } diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index 1d321cf..d5f77a1 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -1237,6 +1237,43 @@ public function testMalformedCustomFieldEntriesAreSkippedNotFatal(): void $this->assertArrayNotHasKey('custom_internal_name_en-US', $additional); } + public function testNonScalarCustomFieldValueIsSkippedNotStringified(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'broken', 'type' => 'text', 'value' => ['a', 'b']], + ['name' => 'nested', 'type' => 'text', 'value' => ['k' => 'v']], + ['name' => 'good', 'type' => 'text', 'value' => 'kept'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertArrayNotHasKey('custom_broken', $additional); + $this->assertArrayNotHasKey('custom_nested', $additional); + $this->assertSame('kept', $additional['custom_good']); + } + + public function testNonScalarLocalizedCustomFieldValueIsSkippedNotStringified(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + [ + 'name' => 'internal_name', + 'type' => 'text', + 'localizedValues' => ['en-US' => ['a', 'b'], 'lt-LT' => 'kept'], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertArrayNotHasKey('custom_internal_name_en-US', $additional); + $this->assertSame('kept', $additional['custom_internal_name_lt-LT']); + } + /** * Helper method to get minimal valid product data. * From c1540579d2ce7f1a9dd751a32a651bf5891797f3 Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 18:16:29 +0300 Subject: [PATCH 05/12] fix(prestashop): keep accepting Stringable localized field values The previous commit tightened addLocalizedField()'s guard from `$value === null` to `!is_scalar($value)` to stop arrays being stringified into the index. That also silently dropped Stringable objects, which used to work via `(string) $value`, on name/description/descriptionShort/brand as well as on localized custom fields. Route every value through stringifyFieldValue(): scalars and Stringable are accepted, arrays and non-stringable objects stay rejected. --- src/Adapters/PrestaShopAdapterV2.php | 30 +++++++--- tests/Adapters/PrestaShopAdapterV2Test.php | 64 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 7e7483a..a349126 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -657,7 +657,7 @@ private function transformTags(array &$result, mixed $tags): void * * @param array $result * @param string $fieldName - * @param array $localizedValues + * @param array $localizedValues */ private function addLocalizedField(array &$result, string $fieldName, array $localizedValues): void { @@ -666,19 +666,35 @@ private function addLocalizedField(array &$result, string $fieldName, array $loc } foreach ($localizedValues as $locale => $value) { - if ( - !is_string($locale) || $locale === '' || - !is_scalar($value) || $value === '' - ) { + if (!is_string($locale) || $locale === '' || $value === '') { continue; } - $cleanValue = strip_tags((string) $value); + $stringValue = $this->stringifyFieldValue($value); - $result["{$fieldName}_{$locale}"] = $cleanValue; + if ($stringValue === null) { + continue; + } + + $result["{$fieldName}_{$locale}"] = strip_tags($stringValue); } } + /** + * Convert a field value to a string, or reject it outright. + * + * Scalars and Stringable objects are accepted. Arrays, null and every other object + * are rejected: stringifying them yields "Array"/a fatal error, not indexable data. + */ + private function stringifyFieldValue(mixed $value): ?string + { + if (is_scalar($value) || $value instanceof \Stringable) { + return (string) $value; + } + + return null; + } + /** * Get required field with validation. * diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index d5f77a1..d73b13e 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -1274,6 +1274,58 @@ public function testNonScalarLocalizedCustomFieldValueIsSkippedNotStringified(): $this->assertSame('kept', $additional['custom_internal_name_lt-LT']); } + public function testStringableValueOnCoreLocalizedFieldIsAccepted(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['localizedNames'] = ['en-US' => new StringableFieldValue('Test Product')]; + $data['description'] = ['en-US' => new StringableFieldValue('

Cotton shirt

')]; + $data['descriptionShort'] = ['en-US' => new StringableFieldValue('Cotton')]; + $data['brand'] = ['localizedNames' => ['en-US' => new StringableFieldValue('Acme')]]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertSame('Test Product', $additional['name_en-US']); + $this->assertSame('Cotton shirt', $additional['description_en-US']); + $this->assertSame('Cotton', $additional['descriptionShort_en-US']); + $this->assertSame('Acme', $additional['brand_en-US']); + } + + public function testStringableValueOnLocalizedCustomFieldIsAccepted(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + [ + 'name' => 'internal_name', + 'type' => 'text', + 'localizedValues' => ['en-US' => new StringableFieldValue('ALPHA-7741')], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + + $this->assertSame( + 'ALPHA-7741', + $result['products'][0]->additionalFields['custom_internal_name_en-US'] + ); + } + + public function testNonStringableValuesOnCoreLocalizedFieldAreRejected(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['localizedNames'] = ['en-US' => 'Test Product', 'lt-LT' => ['nested', 'array']]; + $data['description'] = ['en-US' => new \stdClass()]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertSame('Test Product', $additional['name_en-US']); + $this->assertArrayNotHasKey('name_lt-LT', $additional); + $this->assertArrayNotHasKey('description_en-US', $additional); + } + /** * Helper method to get minimal valid product data. * @@ -1316,3 +1368,15 @@ private function getMinimalProductData(string $id, string $sku): array ]; } } + +class StringableFieldValue implements \Stringable +{ + public function __construct(private string $value) + { + } + + public function __toString(): string + { + return $this->value; + } +} From ad53aec2200f3bb1f4e7dfaea507e2529e8e1fdf Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 18:20:45 +0300 Subject: [PATCH 06/12] fix(customfields): stop strip_tags corrupting custom field values Two defects in one path: strip_tags() drops everything from an unmatched `<` to the end of the string. Custom columns hold codes, size ranges and numeric notes, not HTML bodies, so "30 */ @@ -606,20 +625,58 @@ private function transformCustomFields(array &$result, array $customFields): voi continue; } + $type = isset($field['type']) && is_string($field['type']) ? $field['type'] : self::CUSTOM_FIELD_TYPE_TEXT; $fieldName = 'custom_' . $name; if (isset($field['localizedValues']) && is_array($field['localizedValues'])) { - $this->addLocalizedField($result, $fieldName, $field['localizedValues']); + $this->addLocalizedField($result, $fieldName, $field['localizedValues'], $type); continue; } - if (!isset($field['value']) || !is_scalar($field['value']) || $field['value'] === '') { + if (!isset($field['value'])) { continue; } - $result[$fieldName] = strip_tags((string) $field['value']); + $stringValue = $this->stringifyFieldValue($field['value']); + + if ($stringValue === null) { + continue; + } + + $cleanValue = $this->cleanCustomFieldValue($stringValue, $type); + + if ($cleanValue === null) { + continue; + } + + $result[$fieldName] = $cleanValue; + } + } + + /** + * Clean one custom field value for indexing, or reject it. + * + * HTML is removed from text-typed values only. An integer/double/boolean/date column + * holds codes and numbers, never markup, so running an HTML sanitiser over one can + * only damage it. + * + * The emptiness check runs on the cleaned value, not the raw one: brad-app maps + * non-text custom fields as integer/double/date, and an empty string on one of + * those makes the search backend reject the whole product document. + * + * @return string|null Cleaned value, or null when the field must be skipped + */ + private function cleanCustomFieldValue(string $value, string $type): ?string + { + $cleanValue = $type === self::CUSTOM_FIELD_TYPE_TEXT ? $this->stripHtmlTags($value) : $value; + $cleanValue = trim($cleanValue); + + if ($cleanValue === '') { + return null; } + + return $cleanValue; } /** @@ -658,9 +715,17 @@ private function transformTags(array &$result, mixed $tags): void * @param array $result * @param string $fieldName * @param array $localizedValues + * @param string|null $customFieldType Engine type of the custom field being added, which + * switches on type-aware cleaning. Null (the default) + * keeps the always-strip behaviour the core fields + * name/description/descriptionShort/brand/features rely on. */ - private function addLocalizedField(array &$result, string $fieldName, array $localizedValues): void - { + private function addLocalizedField( + array &$result, + string $fieldName, + array $localizedValues, + ?string $customFieldType = null + ): void { if (empty($localizedValues)) { return; } @@ -676,10 +741,39 @@ private function addLocalizedField(array &$result, string $fieldName, array $loc continue; } - $result["{$fieldName}_{$locale}"] = strip_tags($stringValue); + if ($customFieldType === null) { + $result["{$fieldName}_{$locale}"] = strip_tags($stringValue); + + continue; + } + + $cleanValue = $this->cleanCustomFieldValue($stringValue, $customFieldType); + + if ($cleanValue === null) { + continue; + } + + $result["{$fieldName}_{$locale}"] = $cleanValue; } } + /** + * Remove HTML tags from a custom field value without truncating it at a literal `<`. + * + * strip_tags() discards everything from an unmatched `<` to the end of the string, so on + * its own it corrupts the codes, ranges and notes merchants keep in custom columns: + * "30assertArrayNotHasKey('description_en-US', $additional); } + public function testTextCustomFieldValueWithBareLessThanSurvivesIntact(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'size_range', 'type' => 'text', 'value' => '30adapter->transform(['products' => [$data]]); + + $this->assertSame('30additionalFields['custom_size_range']); + } + + public function testLocalizedTextCustomFieldValueWithBareLessThanSurvivesIntact(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + [ + 'name' => 'size_range', + 'type' => 'text', + 'localizedValues' => ['en-US' => 'S '30adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertSame('SassertSame('30getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'stock_note', 'type' => 'integer', 'value' => '5<3'], + ['name' => 'weight_note', 'type' => 'double', 'value' => '0.5<1.5'], + ['name' => 'flag_note', 'type' => 'boolean', 'value' => 'false 'localized_stock_note', + 'type' => 'integer', + 'localizedValues' => ['en-US' => '5<3'], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertSame('5<3', $additional['custom_stock_note']); + $this->assertSame('0.5<1.5', $additional['custom_weight_note']); + $this->assertSame('falseassertSame('5<3', $additional['custom_localized_stock_note_en-US']); + } + + public function testTextCustomFieldValueThatBecomesEmptyAfterStrippingIsSkipped(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'warehouse_slot', 'type' => 'text', 'value' => ''], + [ + 'name' => 'internal_name', + 'type' => 'text', + 'localizedValues' => ['en-US' => '', 'lt-LT' => 'kept'], + ], + ['name' => 'good', 'type' => 'text', 'value' => 'kept'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertArrayNotHasKey('custom_warehouse_slot', $additional); + $this->assertArrayNotHasKey('custom_internal_name_en-US', $additional); + $this->assertSame('kept', $additional['custom_internal_name_lt-LT']); + $this->assertSame('kept', $additional['custom_good']); + } + + public function testCustomFieldValuesAreTrimmed(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'warehouse_slot', 'type' => 'text', 'value' => 'A-12 '], + ['name' => 'stock_count', 'type' => 'integer', 'value' => ' 42 '], + ['name' => 'blank_slot', 'type' => 'text', 'value' => ' '], + [ + 'name' => 'internal_name', + 'type' => 'text', + 'localizedValues' => ['en-US' => ' ALPHA-7741 ', 'lt-LT' => ' '], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertSame('A-12', $additional['custom_warehouse_slot']); + $this->assertSame('42', $additional['custom_stock_count']); + $this->assertArrayNotHasKey('custom_blank_slot', $additional); + $this->assertSame('ALPHA-7741', $additional['custom_internal_name_en-US']); + $this->assertArrayNotHasKey('custom_internal_name_lt-LT', $additional); + } + /** * Helper method to get minimal valid product data. * From b439820bafdf6c06f2f2840341f2b3677f819dc9 Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 18:21:40 +0300 Subject: [PATCH 07/12] fix(customfields): skip MySQL zero dates on date-typed custom fields A nullable DATE/DATETIME column hands out '0000-00-00 00:00:00' rather than NULL. brad-app maps a date-typed custom field as an ES date, which rejects that value, and one rejected field fails the whole product document. The PrestaShop module already normalizes the zero date away for the core createdAt/updatedAt fields but not for custom columns, so filter it here. Only date-typed fields are affected: a text column may legitimately contain '0000-00-00' as literal content. --- src/Adapters/PrestaShopAdapterV2.php | 19 +++++++++- tests/Adapters/PrestaShopAdapterV2Test.php | 40 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 3cbb9d0..89538bd 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -24,6 +24,18 @@ class PrestaShopAdapterV2 */ private const CUSTOM_FIELD_TYPE_TEXT = 'text'; + /** + * Engine type of DATE/DATETIME/TIMESTAMP columns, per CustomFieldTypeMapper in the module. + */ + private const CUSTOM_FIELD_TYPE_DATE = 'date'; + + /** + * MySQL's zero date, which nullable DATE/DATETIME columns hand out instead of NULL. The + * module normalizes it away for the core createdAt/updatedAt fields but not for custom + * columns, and a date-mapped field that rejects it takes the whole product document with it. + */ + private const MYSQL_ZERO_DATE_PREFIX = '0000-00-00'; + /** * Matches every `<` that cannot open a well-formed HTML tag, i.e. every `<` that is * literal data ("30assertArrayNotHasKey('custom_internal_name_lt-LT', $additional); } + public function testDateCustomFieldWithMysqlZeroDateIsSkipped(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'available_from', 'type' => 'date', 'value' => '0000-00-00 00:00:00'], + ['name' => 'discontinued_on', 'type' => 'date', 'value' => '0000-00-00'], + ['name' => 'restocked_at', 'type' => 'date', 'value' => '2026-01-05 10:00:00'], + [ + 'name' => 'localized_date', + 'type' => 'date', + 'localizedValues' => ['en-US' => '0000-00-00 00:00:00', 'lt-LT' => '2026-01-05'], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertArrayNotHasKey('custom_available_from', $additional); + $this->assertArrayNotHasKey('custom_discontinued_on', $additional); + $this->assertSame('2026-01-05 10:00:00', $additional['custom_restocked_at']); + $this->assertArrayNotHasKey('custom_localized_date_en-US', $additional); + $this->assertSame('2026-01-05', $additional['custom_localized_date_lt-LT']); + } + + public function testTextCustomFieldHoldingMysqlZeroDateIsKept(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'legacy_note', 'type' => 'text', 'value' => '0000-00-00 is the import placeholder'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + + $this->assertSame( + '0000-00-00 is the import placeholder', + $result['products'][0]->additionalFields['custom_legacy_note'] + ); + } + /** * Helper method to get minimal valid product data. * From 5d034d8e7c8be99db3293e997f107c11c50373f5 Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 18:24:49 +0300 Subject: [PATCH 08/12] fix(customfields): validate custom field names before building field paths The name came straight off the network payload and was concatenated into a search field name. A `.` in it becomes an object path in the index, and an over-long name is rejected by the backend. Hold names to the module's own column-name rule, /^[a-zA-Z0-9_]{1,64}$/, and skip the entry otherwise; this also subsumes the previous empty-name check. --- src/Adapters/PrestaShopAdapterV2.php | 10 +++++- tests/Adapters/PrestaShopAdapterV2Test.php | 41 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 89538bd..6be1bb6 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -24,6 +24,14 @@ class PrestaShopAdapterV2 */ private const CUSTOM_FIELD_TYPE_TEXT = 'text'; + /** + * Custom field names arrive over the network and become search field paths, so they are + * held to the module's own column-name rule (EnabledCustomFieldProvider::COLUMN_NAME_PATTERN). + * A `.` would turn the field into an object path in the index; an over-long name would be + * rejected by the backend. + */ + private const CUSTOM_FIELD_NAME_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/'; + /** * Engine type of DATE/DATETIME/TIMESTAMP columns, per CustomFieldTypeMapper in the module. */ @@ -633,7 +641,7 @@ private function transformCustomFields(array &$result, array $customFields): voi } $name = isset($field['name']) && is_string($field['name']) ? $field['name'] : ''; - if ($name === '') { + if (preg_match(self::CUSTOM_FIELD_NAME_PATTERN, $name) !== 1) { continue; } diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index 1cc4f98..b70733f 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -1466,6 +1466,47 @@ public function testTextCustomFieldHoldingMysqlZeroDateIsKept(): void ); } + public function testCustomFieldWithUnsafeNameIsSkipped(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'nested.path', 'type' => 'text', 'value' => 'dotted'], + ['name' => str_repeat('a', 65), 'type' => 'text', 'value' => 'too long'], + ['name' => 'spaced name', 'type' => 'text', 'value' => 'spaced'], + ['name' => 'weird-name', 'type' => 'text', 'value' => 'hyphen'], + [ + 'name' => 'localized.path', + 'type' => 'text', + 'localizedValues' => ['en-US' => 'dotted localized'], + ], + ['name' => 'good', 'type' => 'text', 'value' => 'kept'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertArrayNotHasKey('custom_nested.path', $additional); + $this->assertArrayNotHasKey('custom_' . str_repeat('a', 65), $additional); + $this->assertArrayNotHasKey('custom_spaced name', $additional); + $this->assertArrayNotHasKey('custom_weird-name', $additional); + $this->assertArrayNotHasKey('custom_localized.path_en-US', $additional); + $this->assertSame('kept', $additional['custom_good']); + } + + public function testCustomFieldNameAtMaximumLengthIsKept(): void + { + $name = str_repeat('a', 64); + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => $name, 'type' => 'text', 'value' => 'kept'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + + $this->assertSame('kept', $result['products'][0]->additionalFields['custom_' . $name]); + } + /** * Helper method to get minimal valid product data. * From a8cd8c8bdd8091644d637c566bf2563430efa62d Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 18:27:46 +0300 Subject: [PATCH 09/12] refactor(customfields): expose the custom_ prefix and document the entry shape The `custom_` literal is repeated across three repos, so publish it as PrestaShopAdapterV2::CUSTOM_FIELD_PREFIX and use it internally. The field name shapes (custom_, custom__) are unchanged. Also document the accepted entry shape on transformCustomFields, the way transformFeatures documents its own: this is a wire contract between the PrestaShop module, this SDK and brad-app. --- src/Adapters/PrestaShopAdapterV2.php | 40 +++++++++++++++++++++- tests/Adapters/PrestaShopAdapterV2Test.php | 23 +++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 6be1bb6..e971c41 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -17,6 +17,13 @@ */ class PrestaShopAdapterV2 { + /** + * Prefix every merchant custom product field carries in `additionalFields`. Public because + * the same `custom_` / `custom__` names are built and read by brad-app + * and by the PrestaShop module; consumers should reference this instead of the literal. + */ + public const CUSTOM_FIELD_PREFIX = 'custom_'; + /** * The only engine type whose values are HTML-bearing free text, and so the only * one that may be run through strip_tags(). Mirrors CustomFieldTypeMapper in the @@ -630,6 +637,37 @@ private function transformFeatures(array &$result, array $features): void } /** + * Transform merchant-selected custom product columns to flat prefixed fields. + * + * Input format (one entry per column the merchant enabled, from the PrestaShop module; + * `value` and `localizedValues` are mutually exclusive): + * [ + * 'name' => 'warehouse_slot', + * 'type' => 'text', + * 'value' => 'A-12', + * ] + * [ + * 'name' => 'internal_name', + * 'type' => 'text', + * 'localizedValues' => [ + * 'en-US' => 'Cotton shirt', + * 'lt-LT' => 'Medvilniniai', + * ], + * ] + * + * `name` must match CUSTOM_FIELD_NAME_PATTERN. `type` is one of text, integer, double, + * boolean or date, and defaults to text; boolean values arrive as the strings + * 'true'/'false'. Entries that fail either rule, or whose value cleans down to nothing, + * are skipped rather than reported as errors. + * + * Output format: + * $result['custom_warehouse_slot'] = 'A-12'; + * $result['custom_internal_name_en-US'] = 'Cotton shirt'; + * $result['custom_internal_name_lt-LT'] = 'Medvilniniai'; + * + * Every locale is suffixed, including the first: this adapter takes no locale list and + * has no notion of a default locale, unlike the Shopify and Magento adapters. + * * @param array $result * @param array $customFields */ @@ -646,7 +684,7 @@ private function transformCustomFields(array &$result, array $customFields): voi } $type = isset($field['type']) && is_string($field['type']) ? $field['type'] : self::CUSTOM_FIELD_TYPE_TEXT; - $fieldName = 'custom_' . $name; + $fieldName = self::CUSTOM_FIELD_PREFIX . $name; if (isset($field['localizedValues']) && is_array($field['localizedValues'])) { $this->addLocalizedField($result, $fieldName, $field['localizedValues'], $type); diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index b70733f..5b35d62 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -1507,6 +1507,29 @@ public function testCustomFieldNameAtMaximumLengthIsKept(): void $this->assertSame('kept', $result['products'][0]->additionalFields['custom_' . $name]); } + public function testCustomFieldPrefixConstMatchesTheEmittedFieldNames(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'warehouse_slot', 'type' => 'text', 'value' => 'A-12'], + [ + 'name' => 'internal_name', + 'type' => 'text', + 'localizedValues' => ['en-US' => 'ALPHA-7741'], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertSame('custom_', PrestaShopAdapterV2::CUSTOM_FIELD_PREFIX); + $this->assertSame('A-12', $additional[PrestaShopAdapterV2::CUSTOM_FIELD_PREFIX . 'warehouse_slot']); + $this->assertSame( + 'ALPHA-7741', + $additional[PrestaShopAdapterV2::CUSTOM_FIELD_PREFIX . 'internal_name_en-US'] + ); + } + /** * Helper method to get minimal valid product data. * From bf3104d7dd2f6d0fd2d6b86171f63e455161efb1 Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 18:38:37 +0300 Subject: [PATCH 10/12] docs(customfields): cut the added prose to the non-obvious why --- src/Adapters/PrestaShopAdapterV2.php | 105 +++++---------------------- 1 file changed, 18 insertions(+), 87 deletions(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index e971c41..18c2dda 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -17,50 +17,23 @@ */ class PrestaShopAdapterV2 { - /** - * Prefix every merchant custom product field carries in `additionalFields`. Public because - * the same `custom_` / `custom__` names are built and read by brad-app - * and by the PrestaShop module; consumers should reference this instead of the literal. - */ public const CUSTOM_FIELD_PREFIX = 'custom_'; - /** - * The only engine type whose values are HTML-bearing free text, and so the only - * one that may be run through strip_tags(). Mirrors CustomFieldTypeMapper in the - * PrestaShop module, whose other types are integer, double, boolean and date. - */ private const CUSTOM_FIELD_TYPE_TEXT = 'text'; - /** - * Custom field names arrive over the network and become search field paths, so they are - * held to the module's own column-name rule (EnabledCustomFieldProvider::COLUMN_NAME_PATTERN). - * A `.` would turn the field into an object path in the index; an over-long name would be - * rejected by the backend. - */ + // Mirrors the module's column-name rule: a `.` would make the field an object path in the index. private const CUSTOM_FIELD_NAME_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/'; - /** - * Engine type of DATE/DATETIME/TIMESTAMP columns, per CustomFieldTypeMapper in the module. - */ private const CUSTOM_FIELD_TYPE_DATE = 'date'; - /** - * MySQL's zero date, which nullable DATE/DATETIME columns hand out instead of NULL. The - * module normalizes it away for the core createdAt/updatedAt fields but not for custom - * columns, and a date-mapped field that rejects it takes the whole product document with it. - */ + // Nullable DATE columns hand this out instead of NULL, and a date-mapped field that + // rejects it takes the whole product document down with it. private const MYSQL_ZERO_DATE_PREFIX = '0000-00-00'; - /** - * Matches every `<` that cannot open a well-formed HTML tag, i.e. every `<` that is - * literal data ("30 'warehouse_slot', - * 'type' => 'text', - * 'value' => 'A-12', - * ] - * [ - * 'name' => 'internal_name', - * 'type' => 'text', - * 'localizedValues' => [ - * 'en-US' => 'Cotton shirt', - * 'lt-LT' => 'Medvilniniai', - * ], - * ] - * - * `name` must match CUSTOM_FIELD_NAME_PATTERN. `type` is one of text, integer, double, - * boolean or date, and defaults to text; boolean values arrive as the strings - * 'true'/'false'. Entries that fail either rule, or whose value cleans down to nothing, - * are skipped rather than reported as errors. - * - * Output format: - * $result['custom_warehouse_slot'] = 'A-12'; - * $result['custom_internal_name_en-US'] = 'Cotton shirt'; - * $result['custom_internal_name_lt-LT'] = 'Medvilniniai'; - * * Every locale is suffixed, including the first: this adapter takes no locale list and * has no notion of a default locale, unlike the Shopify and Magento adapters. * * @param array $result - * @param array $customFields + * @param array $customFields Entries of ['name' => string, 'type' => string, + * 'value' => mixed] or 'localizedValues' => [locale => mixed] */ private function transformCustomFields(array &$result, array $customFields): void { @@ -713,16 +659,9 @@ private function transformCustomFields(array &$result, array $customFields): voi } /** - * Clean one custom field value for indexing, or reject it. - * - * HTML is removed from text-typed values only. An integer/double/boolean/date column - * holds codes and numbers, never markup, so running an HTML sanitiser over one can - * only damage it. - * - * The emptiness check runs on the cleaned value, not the raw one: brad-app maps - * non-text custom fields as integer/double/date, and an empty string on one of - * those makes the search backend reject the whole product document. MySQL's zero - * date is rejected on date-typed fields for the same reason. + * The emptiness check runs on the cleaned value, not the raw one: brad-app maps non-text + * custom fields as integer/double/date, and an empty string on one of those makes the + * search backend reject the whole product document. * * @return string|null Cleaned value, or null when the field must be skipped */ @@ -778,10 +717,8 @@ private function transformTags(array &$result, mixed $tags): void * @param array $result * @param string $fieldName * @param array $localizedValues - * @param string|null $customFieldType Engine type of the custom field being added, which - * switches on type-aware cleaning. Null (the default) - * keeps the always-strip behaviour the core fields - * name/description/descriptionShort/brand/features rely on. + * @param string|null $customFieldType Null (the default) keeps the always-strip behaviour + * the core fields rely on. */ private function addLocalizedField( array &$result, @@ -821,13 +758,13 @@ private function addLocalizedField( } /** - * Remove HTML tags from a custom field value without truncating it at a literal `<`. + * strip_tags() alone discards everything from an unmatched `<` to the end of the string, + * corrupting the ranges merchants keep in custom columns ("30` now survives where strip_tags() dropped it. Output escaping, not this + * function, is the boundary that has to hold. */ private function stripHtmlTags(string $value): string { @@ -837,12 +774,6 @@ private function stripHtmlTags(string $value): string return str_replace(self::LITERAL_ANGLE_SENTINEL, '<', strip_tags($guarded)); } - /** - * Convert a field value to a string, or reject it outright. - * - * Scalars and Stringable objects are accepted. Arrays, null and every other object - * are rejected: stringifying them yields "Array"/a fatal error, not indexable data. - */ private function stringifyFieldValue(mixed $value): ?string { if (is_scalar($value) || $value instanceof \Stringable) { From 61ec66a089ee3851a2c15edf36c22635837c5b08 Mon Sep 17 00:00:00 2001 From: GantasG Date: Fri, 28 Aug 2026 18:53:49 +0300 Subject: [PATCH 11/12] docs: drop the explanatory comments from the custom-field fixes --- src/Adapters/PrestaShopAdapterV2.php | 29 +++------------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 18c2dda..18ce2b4 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -21,19 +21,14 @@ class PrestaShopAdapterV2 private const CUSTOM_FIELD_TYPE_TEXT = 'text'; - // Mirrors the module's column-name rule: a `.` would make the field an object path in the index. private const CUSTOM_FIELD_NAME_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/'; private const CUSTOM_FIELD_TYPE_DATE = 'date'; - // Nullable DATE columns hand this out instead of NULL, and a date-mapped field that - // rejects it takes the whole product document down with it. private const MYSQL_ZERO_DATE_PREFIX = '0000-00-00'; - // Every `<` that cannot open a well-formed tag, i.e. is data ("30 $result - * @param array $customFields Entries of ['name' => string, 'type' => string, - * 'value' => mixed] or 'localizedValues' => [locale => mixed] + * @param array $customFields */ private function transformCustomFields(array &$result, array $customFields): void { @@ -659,11 +650,7 @@ private function transformCustomFields(array &$result, array $customFields): voi } /** - * The emptiness check runs on the cleaned value, not the raw one: brad-app maps non-text - * custom fields as integer/double/date, and an empty string on one of those makes the - * search backend reject the whole product document. - * - * @return string|null Cleaned value, or null when the field must be skipped + * @return string|null */ private function cleanCustomFieldValue(string $value, string $type): ?string { @@ -717,8 +704,7 @@ private function transformTags(array &$result, mixed $tags): void * @param array $result * @param string $fieldName * @param array $localizedValues - * @param string|null $customFieldType Null (the default) keeps the always-strip behaviour - * the core fields rely on. + * @param string|null $customFieldType */ private function addLocalizedField( array &$result, @@ -757,15 +743,6 @@ private function addLocalizedField( } } - /** - * strip_tags() alone discards everything from an unmatched `<` to the end of the string, - * corrupting the ranges merchants keep in custom columns ("30` now survives where strip_tags() dropped it. Output escaping, not this - * function, is the boundary that has to hold. - */ private function stripHtmlTags(string $value): string { $guarded = str_replace(self::LITERAL_ANGLE_SENTINEL, '', $value); From b78b05743dc5356c1fc436bc5770166e23392292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulius=20Stuk=C5=A1ys?= Date: Wed, 2 Sep 2026 10:24:47 +0300 Subject: [PATCH 12/12] BRD-1216: normalise boolean custom fields and tighten the name pattern - real PHP booleans become 'true'/'false' instead of '1'/dropped - name pattern anchored with the D modifier so a trailing newline is rejected - tests for booleans, trailing newline, duplicate names and map-shaped input - document the custom field entry shape on transformCustomFields() Co-Authored-By: Claude Fable 5.1 --- src/Adapters/PrestaShopAdapterV2.php | 8 ++- tests/Adapters/PrestaShopAdapterV2Test.php | 68 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/Adapters/PrestaShopAdapterV2.php b/src/Adapters/PrestaShopAdapterV2.php index 18ce2b4..0ac71fd 100644 --- a/src/Adapters/PrestaShopAdapterV2.php +++ b/src/Adapters/PrestaShopAdapterV2.php @@ -21,7 +21,7 @@ class PrestaShopAdapterV2 private const CUSTOM_FIELD_TYPE_TEXT = 'text'; - private const CUSTOM_FIELD_NAME_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/'; + private const CUSTOM_FIELD_NAME_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/D'; private const CUSTOM_FIELD_TYPE_DATE = 'date'; @@ -605,6 +605,8 @@ private function transformFeatures(array &$result, array $features): void } /** + * Entry shape: array{name: string, type: 'text'|'integer'|'double'|'boolean'|'date', value?: mixed, localizedValues?: array} + * * @param array $result * @param array $customFields */ @@ -753,6 +755,10 @@ private function stripHtmlTags(string $value): string private function stringifyFieldValue(mixed $value): ?string { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_scalar($value) || $value instanceof \Stringable) { return (string) $value; } diff --git a/tests/Adapters/PrestaShopAdapterV2Test.php b/tests/Adapters/PrestaShopAdapterV2Test.php index 5b35d62..a922d67 100644 --- a/tests/Adapters/PrestaShopAdapterV2Test.php +++ b/tests/Adapters/PrestaShopAdapterV2Test.php @@ -1494,6 +1494,74 @@ public function testCustomFieldWithUnsafeNameIsSkipped(): void $this->assertSame('kept', $additional['custom_good']); } + public function testBooleanCustomFieldValuesAreNormalisedToTrueFalseStrings(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'is_featured', 'type' => 'boolean', 'value' => true], + ['name' => 'is_hidden', 'type' => 'boolean', 'value' => false], + [ + 'name' => 'is_local', + 'type' => 'boolean', + 'localizedValues' => ['en-US' => true, 'lt-LT' => false], + ], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertSame('true', $additional['custom_is_featured']); + $this->assertSame('false', $additional['custom_is_hidden']); + $this->assertSame('true', $additional['custom_is_local_en-US']); + $this->assertSame('false', $additional['custom_is_local_lt-LT']); + } + + public function testCustomFieldNameWithTrailingNewlineIsSkipped(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => "good\n", 'type' => 'text', 'value' => 'newline'], + ['name' => 'good', 'type' => 'text', 'value' => 'kept'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertArrayNotHasKey("custom_good\n", $additional); + $this->assertSame('kept', $additional['custom_good']); + } + + public function testDuplicateCustomFieldNameLastEntryWins(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + ['name' => 'warehouse_slot', 'type' => 'text', 'value' => 'first'], + ['name' => 'warehouse_slot', 'type' => 'text', 'value' => 'second'], + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + + $this->assertSame('second', $result['products'][0]->additionalFields['custom_warehouse_slot']); + } + + public function testCustomFieldsGivenAsAssociativeMapIsHandledWithoutErrors(): void + { + $data = $this->getMinimalProductData('1807', 'SKU-123'); + $data['customFields'] = [ + 'warehouse_slot' => ['name' => 'warehouse_slot', 'type' => 'text', 'value' => 'A-12'], + 'stock_count' => '42', + ]; + + $result = $this->adapter->transform(['products' => [$data]]); + $additional = $result['products'][0]->additionalFields; + + $this->assertCount(0, $result['errors']); + $this->assertSame('A-12', $additional['custom_warehouse_slot']); + $this->assertArrayNotHasKey('custom_stock_count', $additional); + } + public function testCustomFieldNameAtMaximumLengthIsKept(): void { $name = str_repeat('a', 64);