diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
index 6e5393c3..846a8c0a 100644
--- a/.php-cs-fixer.php
+++ b/.php-cs-fixer.php
@@ -29,6 +29,7 @@
->in(__DIR__ . '/src/Services/SonetGroup/')
->in(__DIR__ . '/src/Services/IMOpenLines/')
->in(__DIR__ . '/src/Services/Landing/')
+ ->in(__DIR__ . '/src/Services/Catalog/')
->name('*.php')
->exclude(['vendor', 'storage', 'docker', 'docs']) // Exclude directories
->ignoreDotFiles(true)
@@ -40,4 +41,4 @@
->setFinder($finder)
->setRules([
'@PSR12' => true, // PSR-12 coding standards
- ]);
\ No newline at end of file
+ ]);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4bc3b52..2a76091e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@
### Added
+- Added service `Services\Catalog\ProductProperty` with support methods,
+ see [catalog.productProperty.* methods](https://apidocs.bitrix24.com/api-reference/catalog/product-property/index.html) ([#538](https://github.com/bitrix24/b24phpsdk/issues/538)):
+ - `add` creates a new product or variation property, with batch calls support
+ - `update` updates an existing product or variation property, with batch calls support
+ - `get` gets information about a product or variation property by its identifier
+ - `list` gets the list of product and variation properties by filter, with batch calls support
+ - `delete` deletes a product or variation property, with batch calls support
+ - `getFields` returns the description of product or variation property fields
- Added service `Services\Landing\Site\Service\Site` with support methods,
see [landing.site.* methods](https://github.com/bitrix24/b24phpsdk/issues/267):
- `add` adds a site
diff --git a/Makefile b/Makefile
index c2fc4a9a..8b287086 100644
--- a/Makefile
+++ b/Makefile
@@ -492,6 +492,18 @@ test-integration-landing-role:
test-integration-landing-repowidget:
docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_landing_repowidget
+.PHONY: integration-tests-catalog-product-property
+integration-tests-catalog-product-property:
+ docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_catalog_product_property
+
+.PHONY: integration-tests-catalog-product-property-service
+integration-tests-catalog-product-property-service:
+ docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_catalog_product_property_service
+
+.PHONY: integration-tests-catalog-product-property-annotations
+integration-tests-catalog-product-property-annotations:
+ docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_catalog_product_property_annotations
+
# work dev environment
.PHONY: php-dev-server-up
php-dev-server-up:
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index d0187ae5..1eab5506 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -262,6 +262,15 @@
./tests/Integration/Services/Landing/RepoWidget/
+
+ ./tests/Integration/Services/Catalog/ProductProperty/
+
+
+ ./tests/Integration/Services/Catalog/ProductProperty/Service/
+
+
+ ./tests/Integration/Services/Catalog/ProductProperty/Result/ProductPropertyItemResultAnnotationsTest.php
+
diff --git a/src/Services/Catalog/Catalog/Result/CatalogItemResult.php b/src/Services/Catalog/Catalog/Result/CatalogItemResult.php
index e62fc7cb..189852bb 100644
--- a/src/Services/Catalog/Catalog/Result/CatalogItemResult.php
+++ b/src/Services/Catalog/Catalog/Result/CatalogItemResult.php
@@ -29,4 +29,4 @@
*/
class CatalogItemResult extends AbstractCatalogItem
{
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Catalog/Result/CatalogResult.php b/src/Services/Catalog/Catalog/Result/CatalogResult.php
index aa468aa8..c5222f11 100644
--- a/src/Services/Catalog/Catalog/Result/CatalogResult.php
+++ b/src/Services/Catalog/Catalog/Result/CatalogResult.php
@@ -21,4 +21,4 @@ public function catalog(): CatalogItemResult
{
return new CatalogItemResult($this->getCoreResponse()->getResponseData()->getResult()['catalog']);
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Catalog/Result/CatalogsResult.php b/src/Services/Catalog/Catalog/Result/CatalogsResult.php
index add5f259..f721c3bb 100644
--- a/src/Services/Catalog/Catalog/Result/CatalogsResult.php
+++ b/src/Services/Catalog/Catalog/Result/CatalogsResult.php
@@ -32,4 +32,4 @@ public function getCatalogs(): array
return $res;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Catalog/Service/Catalog.php b/src/Services/Catalog/Catalog/Service/Catalog.php
index d9cb55fe..0da76ea3 100644
--- a/src/Services/Catalog/Catalog/Service/Catalog.php
+++ b/src/Services/Catalog/Catalog/Service/Catalog.php
@@ -82,4 +82,4 @@ public function fields(): FieldsResult
{
return new FieldsResult($this->core->call('catalog.catalog.getFields'));
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/CatalogServiceBuilder.php b/src/Services/Catalog/CatalogServiceBuilder.php
index 57c91b88..d5809ca5 100644
--- a/src/Services/Catalog/CatalogServiceBuilder.php
+++ b/src/Services/Catalog/CatalogServiceBuilder.php
@@ -17,6 +17,7 @@
use Bitrix24\SDK\Core\Credentials\Scope;
use Bitrix24\SDK\Services\AbstractServiceBuilder;
use Bitrix24\SDK\Services\Catalog;
+
#[ApiServiceBuilderMetadata(new Scope(['catalog']))]
class CatalogServiceBuilder extends AbstractServiceBuilder
{
@@ -44,4 +45,21 @@ public function catalog(): Catalog\Catalog\Service\Catalog
return $this->serviceCache[__METHOD__];
}
-}
\ No newline at end of file
+
+ public function productProperty(): Catalog\ProductProperty\Service\ProductProperty
+ {
+ if (!isset($this->serviceCache[__METHOD__])) {
+ $batch = new Catalog\ProductProperty\Batch(
+ $this->core,
+ $this->log
+ );
+ $this->serviceCache[__METHOD__] = new Catalog\ProductProperty\Service\ProductProperty(
+ new Catalog\ProductProperty\Service\Batch($batch, $this->log),
+ $this->core,
+ $this->log
+ );
+ }
+
+ return $this->serviceCache[__METHOD__];
+ }
+}
diff --git a/src/Services/Catalog/Common/ProductType.php b/src/Services/Catalog/Common/ProductType.php
index 0bf8ef09..ad568a47 100644
--- a/src/Services/Catalog/Common/ProductType.php
+++ b/src/Services/Catalog/Common/ProductType.php
@@ -20,4 +20,4 @@ enum ProductType: int
case SKU = 3;
case productOffer = 4;
case genericOffer = 5;
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Common/Result/AbstractCatalogItem.php b/src/Services/Catalog/Common/Result/AbstractCatalogItem.php
index e2929d73..9761daed 100644
--- a/src/Services/Catalog/Common/Result/AbstractCatalogItem.php
+++ b/src/Services/Catalog/Common/Result/AbstractCatalogItem.php
@@ -109,4 +109,4 @@ protected function getKeyWithUserfieldByFieldName(string $fieldName)
return $this->$fieldName;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Product/Result/ProductItemResult.php b/src/Services/Catalog/Product/Result/ProductItemResult.php
index c576d9b7..58c80851 100644
--- a/src/Services/Catalog/Product/Result/ProductItemResult.php
+++ b/src/Services/Catalog/Product/Result/ProductItemResult.php
@@ -52,4 +52,4 @@
*/
class ProductItemResult extends AbstractCatalogItem
{
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Product/Result/ProductResult.php b/src/Services/Catalog/Product/Result/ProductResult.php
index 2751fda8..fc83fa8c 100644
--- a/src/Services/Catalog/Product/Result/ProductResult.php
+++ b/src/Services/Catalog/Product/Result/ProductResult.php
@@ -26,4 +26,4 @@ public function product(): ProductItemResult
return new ProductItemResult($this->getCoreResponse()->getResponseData()->getResult()['product']);
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Product/Result/ProductsResult.php b/src/Services/Catalog/Product/Result/ProductsResult.php
index aa644fa3..268d6d8f 100644
--- a/src/Services/Catalog/Product/Result/ProductsResult.php
+++ b/src/Services/Catalog/Product/Result/ProductsResult.php
@@ -31,4 +31,4 @@ public function getProducts(): array
return $res;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Product/Service/Batch.php b/src/Services/Catalog/Product/Service/Batch.php
index 45dddcb7..42970546 100644
--- a/src/Services/Catalog/Product/Service/Batch.php
+++ b/src/Services/Catalog/Product/Service/Batch.php
@@ -25,7 +25,7 @@
{
public function __construct(
protected BatchOperationsInterface $batch,
- protected LoggerInterface $log)
- {
+ protected LoggerInterface $log
+ ) {
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/Product/Service/Product.php b/src/Services/Catalog/Product/Service/Product.php
index 730fd7c9..d37d34f6 100644
--- a/src/Services/Catalog/Product/Service/Product.php
+++ b/src/Services/Catalog/Product/Service/Product.php
@@ -25,7 +25,6 @@
use Bitrix24\SDK\Services\Catalog\Common\ProductType;
use Bitrix24\SDK\Services\Catalog\Product\Result\ProductResult;
use Bitrix24\SDK\Services\Catalog\Product\Result\ProductsResult;
-
use Psr\Log\LoggerInterface;
#[ApiServiceMetadata(new Scope(['catalog']))]
@@ -35,8 +34,7 @@ public function __construct(
public Batch $batch,
CoreInterface $core,
LoggerInterface $logger
- )
- {
+ ) {
parent::__construct($core, $logger);
}
@@ -71,7 +69,9 @@ public function get(int $productId): ProductResult
)]
public function add(array $productFields): ProductResult
{
- return new ProductResult($this->core->call('catalog.product.add', [
+ return new ProductResult($this->core->call(
+ 'catalog.product.add',
+ [
'fields' => $productFields
]
));
@@ -140,4 +140,4 @@ public function fieldsByFilter(int $iblockId, ProductType $productType, ?array $
return new FieldsResult($this->core->call('catalog.product.getFieldsByFilter', ['filter' => $filter]));
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Catalog/ProductProperty/Batch.php b/src/Services/Catalog/ProductProperty/Batch.php
new file mode 100644
index 00000000..e225f129
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Batch.php
@@ -0,0 +1,109 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty;
+
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
+use Bitrix24\SDK\Core\Response\DTO\ResponseData;
+use Generator;
+
+/**
+ * Class Batch
+ *
+ * Overrides base Batch to handle a parameter naming difference in catalog.productProperty.* REST
+ * methods: delete uses lowercase 'id' instead of 'ID'.
+ *
+ * @see https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-delete.html
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty
+ */
+class Batch extends \Bitrix24\SDK\Core\Batch
+{
+ /**
+ * Determines the ID key — lowercase 'id' for catalog product property
+ */
+ #[\Override]
+ protected function determineKeyId(string $apiMethod, ?array $additionalParameters): string
+ {
+ return 'id';
+ }
+
+ /**
+ * Delete entity items with batch call
+ *
+ * `catalog.productProperty.delete` expects a lowercase `id` parameter, unlike the base class
+ * which sends `ID` (uppercase) — override to match the real API contract.
+ *
+ * @return Generator|ResponseData[]
+ * @throws BaseException
+ */
+ #[\Override]
+ public function deleteEntityItems(
+ string $apiMethod,
+ array $entityItemId,
+ ?array $additionalParameters = null
+ ): Generator {
+ $this->logger->debug(
+ 'deleteEntityItems.start',
+ [
+ 'apiMethod' => $apiMethod,
+ 'entityItems' => $entityItemId,
+ 'additionalParameters' => $additionalParameters,
+ ]
+ );
+
+ try {
+ $this->clearCommands();
+ foreach ($entityItemId as $cnt => $itemId) {
+ if (!is_int($itemId)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'invalid type «%s» of entity id «%s» at position %s, entity id must be integer type',
+ gettype($itemId),
+ $itemId,
+ $cnt
+ )
+ );
+ }
+
+ $this->registerCommand($apiMethod, ['id' => $itemId]);
+ }
+
+ foreach ($this->getTraversable(true) as $cnt => $deletedItemResult) {
+ yield $cnt => $deletedItemResult;
+ }
+ } catch (InvalidArgumentException $exception) {
+ $errorMessage = sprintf('batch delete entity items: %s', $exception->getMessage());
+ $this->logger->error(
+ $errorMessage,
+ [
+ 'trace' => $exception->getTrace(),
+ ]
+ );
+ throw $exception;
+ } catch (\Throwable $exception) {
+ $errorMessage = sprintf('batch delete entity items: %s', $exception->getMessage());
+ $this->logger->error(
+ $errorMessage,
+ [
+ 'trace' => $exception->getTrace(),
+ ]
+ );
+
+ throw new BaseException($errorMessage, $exception->getCode(), $exception);
+ }
+
+ $this->logger->debug('deleteEntityItems.finish');
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/AddedProductPropertyBatchResult.php b/src/Services/Catalog/ProductProperty/Result/AddedProductPropertyBatchResult.php
new file mode 100644
index 00000000..e8b2d5f8
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/AddedProductPropertyBatchResult.php
@@ -0,0 +1,30 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Result\AddedItemBatchResult;
+
+/**
+ * Class AddedProductPropertyBatchResult
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty\Result
+ */
+class AddedProductPropertyBatchResult extends AddedItemBatchResult
+{
+ #[\Override]
+ public function getId(): int
+ {
+ return (int)$this->getResponseData()->getResult()['productProperty']['id'];
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/DeletedProductPropertyBatchResult.php b/src/Services/Catalog/ProductProperty/Result/DeletedProductPropertyBatchResult.php
new file mode 100644
index 00000000..af73ed68
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/DeletedProductPropertyBatchResult.php
@@ -0,0 +1,30 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Result\DeletedItemBatchResult;
+
+/**
+ * Class DeletedProductPropertyBatchResult
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty\Result
+ */
+class DeletedProductPropertyBatchResult extends DeletedItemBatchResult
+{
+ #[\Override]
+ public function isSuccess(): bool
+ {
+ return (bool)$this->getResponseData()->getResult();
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/DeletedProductPropertyResult.php b/src/Services/Catalog/ProductProperty/Result/DeletedProductPropertyResult.php
new file mode 100644
index 00000000..2d4c62a7
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/DeletedProductPropertyResult.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Result\DeletedItemResult;
+
+/**
+ * Class DeletedProductPropertyResult
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty\Result
+ */
+class DeletedProductPropertyResult extends DeletedItemResult
+{
+ /**
+ * @throws BaseException
+ */
+ #[\Override]
+ public function isSuccess(): bool
+ {
+ return (bool)$this->getCoreResponse()->getResponseData()->getResult();
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/ProductPropertiesResult.php b/src/Services/Catalog/ProductProperty/Result/ProductPropertiesResult.php
new file mode 100644
index 00000000..47a90c18
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/ProductPropertiesResult.php
@@ -0,0 +1,39 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Result\AbstractResult;
+
+/**
+ * Class ProductPropertiesResult
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty\Result
+ */
+class ProductPropertiesResult extends AbstractResult
+{
+ /**
+ * @return ProductPropertyItemResult[]
+ * @throws BaseException
+ */
+ public function getProductProperties(): array
+ {
+ $items = [];
+ foreach ($this->getCoreResponse()->getResponseData()->getResult()['productProperties'] as $item) {
+ $items[] = new ProductPropertyItemResult($item);
+ }
+
+ return $items;
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/ProductPropertyFieldsResult.php b/src/Services/Catalog/ProductProperty/Result/ProductPropertyFieldsResult.php
new file mode 100644
index 00000000..a094eecd
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/ProductPropertyFieldsResult.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Result\FieldsResult;
+
+/**
+ * Class ProductPropertyFieldsResult
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty\Result
+ */
+class ProductPropertyFieldsResult extends FieldsResult
+{
+ /**
+ * @throws BaseException
+ */
+ #[\Override]
+ public function getFieldsDescription(): array
+ {
+ return $this->getCoreResponse()->getResponseData()->getResult()['productProperty'];
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/ProductPropertyItemResult.php b/src/Services/Catalog/ProductProperty/Result/ProductPropertyItemResult.php
new file mode 100644
index 00000000..db700f32
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/ProductPropertyItemResult.php
@@ -0,0 +1,92 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Result\AbstractItem;
+use Carbon\CarbonImmutable;
+
+/**
+ * Class ProductPropertyItemResult
+ *
+ * @property-read bool $active
+ * @property-read string|null $code
+ * @property-read int $colCount
+ * @property-read string|null $defaultValue
+ * @property-read string|null $fileType
+ * @property-read bool $filtrable
+ * @property-read string|null $hint
+ * @property-read int $iblockId
+ * @property-read int $id
+ * @property-read bool $isRequired
+ * @property-read int|null $linkIblockId
+ * @property-read string $listType
+ * @property-read bool $multiple
+ * @property-read int|null $multipleCnt
+ * @property-read string $name
+ * @property-read string $propertyType
+ * @property-read int $rowCount
+ * @property-read bool $searchable
+ * @property-read int|null $sort
+ * @property-read CarbonImmutable $timestampX
+ * @property-read string|null $userType
+ * @property-read array|null $userTypeSettings
+ * @property-read bool|null $withDescription
+ * @property-read string|null $xmlId
+ */
+class ProductPropertyItemResult extends AbstractItem
+{
+ /**
+ * @param int|string $offset
+ *
+ * @return bool|CarbonImmutable|int|mixed|null
+ */
+ #[\Override]
+ public function __get($offset)
+ {
+ switch ($offset) {
+ case 'active':
+ case 'filtrable':
+ case 'isRequired':
+ case 'multiple':
+ case 'searchable':
+ return $this->data[$offset] === 'Y';
+ case 'withDescription':
+ if ($this->data[$offset] !== null) {
+ return $this->data[$offset] === 'Y';
+ }
+
+ return null;
+ case 'colCount':
+ case 'iblockId':
+ case 'id':
+ case 'linkIblockId':
+ case 'multipleCnt':
+ case 'rowCount':
+ case 'sort':
+ if ($this->data[$offset] !== '' && $this->data[$offset] !== null) {
+ return (int)$this->data[$offset];
+ }
+
+ return null;
+ case 'timestampX':
+ if ($this->data[$offset] !== null && $this->data[$offset] !== '') {
+ return CarbonImmutable::createFromFormat(DATE_ATOM, $this->data[$offset]);
+ }
+
+ return null;
+ }
+
+ return $this->data[$offset] ?? null;
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/ProductPropertyResult.php b/src/Services/Catalog/ProductProperty/Result/ProductPropertyResult.php
new file mode 100644
index 00000000..5154cb27
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/ProductPropertyResult.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Result\AbstractResult;
+
+/**
+ * Class ProductPropertyResult
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty\Result
+ */
+class ProductPropertyResult extends AbstractResult
+{
+ /**
+ * @throws BaseException
+ */
+ public function productProperty(): ProductPropertyItemResult
+ {
+ return new ProductPropertyItemResult(
+ $this->getCoreResponse()->getResponseData()->getResult()['productProperty']
+ );
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Result/UpdatedProductPropertyBatchResult.php b/src/Services/Catalog/ProductProperty/Result/UpdatedProductPropertyBatchResult.php
new file mode 100644
index 00000000..3d85ae56
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Result/UpdatedProductPropertyBatchResult.php
@@ -0,0 +1,30 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Result\UpdatedItemBatchResult;
+
+/**
+ * Class UpdatedProductPropertyBatchResult
+ *
+ * @package Bitrix24\SDK\Services\Catalog\ProductProperty\Result
+ */
+class UpdatedProductPropertyBatchResult extends UpdatedItemBatchResult
+{
+ #[\Override]
+ public function isSuccess(): bool
+ {
+ return (bool)$this->getResponseData()->getResult();
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Service/Batch.php b/src/Services/Catalog/ProductProperty/Service/Batch.php
new file mode 100644
index 00000000..d18237a5
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Service/Batch.php
@@ -0,0 +1,134 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Service;
+
+use Bitrix24\SDK\Attributes\ApiBatchMethodMetadata;
+use Bitrix24\SDK\Attributes\ApiBatchServiceMetadata;
+use Bitrix24\SDK\Core\Contracts\BatchOperationsInterface;
+use Bitrix24\SDK\Core\Credentials\Scope;
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\AddedProductPropertyBatchResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\DeletedProductPropertyBatchResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertyItemResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\UpdatedProductPropertyBatchResult;
+use Generator;
+use Psr\Log\LoggerInterface;
+
+#[ApiBatchServiceMetadata(new Scope(['catalog']))]
+readonly class Batch
+{
+ /**
+ * Batch constructor
+ */
+ public function __construct(protected BatchOperationsInterface $batch, protected LoggerInterface $log)
+ {
+ }
+
+ /**
+ * Batch list method for product properties
+ *
+ * @link https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-list.html
+ *
+ * @return Generator
+ * @throws BaseException
+ */
+ #[ApiBatchMethodMetadata(
+ 'catalog.productProperty.list',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-list.html',
+ 'Batch list method for product properties'
+ )]
+ public function list(array $select = [], array $filter = [], array $order = [], ?int $limit = null): Generator
+ {
+ $itemsGenerator = $this->batch->getTraversableListWithCount(
+ 'catalog.productProperty.list',
+ $order,
+ $filter,
+ $select,
+ $limit
+ );
+ foreach ($itemsGenerator as $key => $value) {
+ yield $key => new ProductPropertyItemResult($value);
+ }
+ }
+
+ /**
+ * Batch adding product properties
+ *
+ * @param array $productProperties
+ *
+ * @return Generator
+ * @throws BaseException
+ */
+ #[ApiBatchMethodMetadata(
+ 'catalog.productProperty.add',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-add.html',
+ 'Batch adding product properties'
+ )]
+ public function add(array $productProperties): Generator
+ {
+ $items = [];
+ foreach ($productProperties as $item) {
+ $items[] = ['fields' => $item];
+ }
+
+ foreach ($this->batch->addEntityItems('catalog.productProperty.add', $items) as $key => $item) {
+ yield $key => new AddedProductPropertyBatchResult($item);
+ }
+ }
+
+ /**
+ * Batch update product properties
+ *
+ * Update elements in array with structure:
+ * id => [ // Property id
+ * 'fields' => [] // Property fields to update, must include iblockId
+ * ]
+ *
+ * @param array $entityItems
+ *
+ * @return Generator
+ * @throws BaseException
+ */
+ #[ApiBatchMethodMetadata(
+ 'catalog.productProperty.update',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-update.html',
+ 'Batch update product properties'
+ )]
+ public function update(array $entityItems): Generator
+ {
+ foreach ($this->batch->updateEntityItems('catalog.productProperty.update', $entityItems) as $key => $item) {
+ yield $key => new UpdatedProductPropertyBatchResult($item);
+ }
+ }
+
+ /**
+ * Batch delete product properties
+ *
+ * @param int[] $productPropertyId
+ *
+ * @return Generator
+ * @throws BaseException
+ */
+ #[ApiBatchMethodMetadata(
+ 'catalog.productProperty.delete',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-delete.html',
+ 'Batch delete product properties'
+ )]
+ public function delete(array $productPropertyId): Generator
+ {
+ foreach ($this->batch->deleteEntityItems('catalog.productProperty.delete', $productPropertyId) as $key => $item) {
+ yield $key => new DeletedProductPropertyBatchResult($item);
+ }
+ }
+}
diff --git a/src/Services/Catalog/ProductProperty/Service/ProductProperty.php b/src/Services/Catalog/ProductProperty/Service/ProductProperty.php
new file mode 100644
index 00000000..e594de5e
--- /dev/null
+++ b/src/Services/Catalog/ProductProperty/Service/ProductProperty.php
@@ -0,0 +1,166 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Services\Catalog\ProductProperty\Service;
+
+use Bitrix24\SDK\Attributes\ApiEndpointMetadata;
+use Bitrix24\SDK\Attributes\ApiServiceMetadata;
+use Bitrix24\SDK\Core\Contracts\CoreInterface;
+use Bitrix24\SDK\Core\Credentials\Scope;
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Exceptions\TransportException;
+use Bitrix24\SDK\Services\AbstractService;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\DeletedProductPropertyResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertiesResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertyFieldsResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertyResult;
+use Psr\Log\LoggerInterface;
+
+#[ApiServiceMetadata(new Scope(['catalog']))]
+class ProductProperty extends AbstractService
+{
+ /**
+ * ProductProperty constructor
+ */
+ public function __construct(public Batch $batch, CoreInterface $core, LoggerInterface $logger)
+ {
+ parent::__construct($core, $logger);
+ }
+
+ /**
+ * Adds a product or variation property to the commercial catalog
+ *
+ * @link https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-add.html
+ *
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[ApiEndpointMetadata(
+ 'catalog.productProperty.add',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-add.html',
+ 'Adds a product or variation property to the commercial catalog'
+ )]
+ public function add(array $fields): ProductPropertyResult
+ {
+ return new ProductPropertyResult(
+ $this->core->call('catalog.productProperty.add', ['fields' => $fields])
+ );
+ }
+
+ /**
+ * Updates fields of a product or variation property in the commercial catalog
+ *
+ * NOTE: despite the official docs marking `iblockId` as optional in `fields`, the live API
+ * requires it — omitting it fails with "Required fields: iblockId". Callers must always pass
+ * `iblockId` in $fields.
+ *
+ * @link https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-update.html
+ *
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[ApiEndpointMetadata(
+ 'catalog.productProperty.update',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-update.html',
+ 'Updates fields of a product or variation property in the commercial catalog'
+ )]
+ public function update(int $id, array $fields): ProductPropertyResult
+ {
+ return new ProductPropertyResult(
+ $this->core->call('catalog.productProperty.update', ['id' => $id, 'fields' => $fields])
+ );
+ }
+
+ /**
+ * Returns the values of the product or variation property fields by its identifier
+ *
+ * @link https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-get.html
+ *
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[ApiEndpointMetadata(
+ 'catalog.productProperty.get',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-get.html',
+ 'Returns the values of the product or variation property fields by its identifier'
+ )]
+ public function get(int $id): ProductPropertyResult
+ {
+ return new ProductPropertyResult($this->core->call('catalog.productProperty.get', ['id' => $id]));
+ }
+
+ /**
+ * Returns a list of product and variation properties by filter
+ *
+ * @link https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-list.html
+ *
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[ApiEndpointMetadata(
+ 'catalog.productProperty.list',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-list.html',
+ 'Returns a list of product and variation properties by filter'
+ )]
+ public function list(array $select = [], array $filter = [], array $order = []): ProductPropertiesResult
+ {
+ return new ProductPropertiesResult(
+ $this->core->call(
+ 'catalog.productProperty.list',
+ [
+ 'select' => $select,
+ 'filter' => $filter,
+ 'order' => $order,
+ ]
+ )
+ );
+ }
+
+ /**
+ * Removes a product or variation property by its identifier
+ *
+ * @link https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-delete.html
+ *
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[ApiEndpointMetadata(
+ 'catalog.productProperty.delete',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-delete.html',
+ 'Removes a product or variation property by its identifier'
+ )]
+ public function delete(int $id): DeletedProductPropertyResult
+ {
+ return new DeletedProductPropertyResult(
+ $this->core->call('catalog.productProperty.delete', ['id' => $id])
+ );
+ }
+
+ /**
+ * Returns the description of product or variation property fields
+ *
+ * @link https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-get-fields.html
+ *
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[ApiEndpointMetadata(
+ 'catalog.productProperty.getFields',
+ 'https://apidocs.bitrix24.com/api-reference/catalog/product-property/catalog-product-property-get-fields.html',
+ 'Returns the description of product or variation property fields'
+ )]
+ public function getFields(): ProductPropertyFieldsResult
+ {
+ return new ProductPropertyFieldsResult($this->core->call('catalog.productProperty.getFields'));
+ }
+}
diff --git a/tests/CustomAssertions/CustomBitrix24Assertions.php b/tests/CustomAssertions/CustomBitrix24Assertions.php
index dd1d32b7..5bba93c0 100644
--- a/tests/CustomAssertions/CustomBitrix24Assertions.php
+++ b/tests/CustomAssertions/CustomBitrix24Assertions.php
@@ -90,6 +90,7 @@ protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation(
// mapping internal bitrix24 types to bitrix24 sdk types
switch ($fieldData['type']) {
case 'string':
+ case 'text':
case 'crm_currency':
case 'crm_status':
if (str_contains($fieldCode, 'ACTIVE')) {
@@ -252,6 +253,20 @@ protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation(
);
break;
case 'char':
+ if ($fieldCode === 'listType') {
+ $this->assertTrue(
+ str_contains($propsFromAnnotations[$fieldCode], 'string'),
+ sprintf(
+ 'class «%s» field «%s» has invalid type phpdoc annotation «%s», field type from bitrix24 is «%s», expected sdk-type «%s»',
+ $resultItemClassName,
+ $fieldCode,
+ $propsFromAnnotations[$fieldCode],
+ $fieldData['type'],
+ 'string'
+ )
+ );
+ break;
+ }
$this->assertTrue(
str_contains($propsFromAnnotations[$fieldCode], 'bool'),
sprintf(
@@ -472,6 +487,7 @@ protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation(
case 'attached_diskfile':
case 'disk_file':
case 'datatype':
+ case 'productpropertysettings':
$this->assertTrue(
str_contains($propsFromAnnotations[$fieldCode], 'array'),
sprintf(
diff --git a/tests/Integration/Services/Catalog/ProductProperty/Result/ProductPropertyItemResultAnnotationsTest.php b/tests/Integration/Services/Catalog/ProductProperty/Result/ProductPropertyItemResultAnnotationsTest.php
new file mode 100644
index 00000000..61b5a9ba
--- /dev/null
+++ b/tests/Integration/Services/Catalog/ProductProperty/Result/ProductPropertyItemResultAnnotationsTest.php
@@ -0,0 +1,67 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Tests\Integration\Services\Catalog\ProductProperty\Result;
+
+use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
+use Bitrix24\SDK\Core\Fields\FieldsFilter;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertyItemResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Service\ProductProperty;
+use Bitrix24\SDK\Tests\CustomAssertions\CustomBitrix24Assertions;
+use Bitrix24\SDK\Tests\Integration\Fabric;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(ProductPropertyItemResult::class)]
+class ProductPropertyItemResultAnnotationsTest extends TestCase
+{
+ use CustomBitrix24Assertions;
+
+ private ProductProperty $productPropertyService;
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ #[\Override]
+ protected function setUp(): void
+ {
+ $this->productPropertyService = Fabric::getServiceBuilder()->getCatalogScope()->productProperty();
+ }
+
+ #[Test]
+ #[TestDox('all fields in ProductPropertyItemResult are annotated in phpdoc and match with raw api response')]
+ public function testAllSystemFieldsAnnotated(): void
+ {
+ $propListFromApi = (new FieldsFilter())->filterSystemFields(
+ array_keys($this->productPropertyService->getFields()->getFieldsDescription())
+ );
+
+ $this->assertBitrix24AllResultItemFieldsAnnotated($propListFromApi, ProductPropertyItemResult::class);
+ }
+
+ #[Test]
+ #[TestDox('all fields in ProductPropertyItemResult have valid type casting in magic getters')]
+ public function testAllSystemFieldsHasValidTypeAnnotation(): void
+ {
+ $allFields = $this->productPropertyService->getFields()->getFieldsDescription();
+ $systemFieldsCodes = (new FieldsFilter())->filterSystemFields(array_keys($allFields));
+ $systemFields = array_filter($allFields, static fn($code): bool => in_array($code, $systemFieldsCodes, true), ARRAY_FILTER_USE_KEY);
+
+ $this->assertBitrix24AllResultItemFieldsHasValidTypeAnnotation(
+ $systemFields,
+ ProductPropertyItemResult::class
+ );
+ }
+}
diff --git a/tests/Integration/Services/Catalog/ProductProperty/Service/BatchTest.php b/tests/Integration/Services/Catalog/ProductProperty/Service/BatchTest.php
new file mode 100644
index 00000000..5366396b
--- /dev/null
+++ b/tests/Integration/Services/Catalog/ProductProperty/Service/BatchTest.php
@@ -0,0 +1,193 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Tests\Integration\Services\Catalog\ProductProperty\Service;
+
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
+use Bitrix24\SDK\Core\Exceptions\TransportException;
+use Bitrix24\SDK\Services\Catalog\Catalog\Service\Catalog;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Service\ProductProperty;
+use Bitrix24\SDK\Tests\Integration\Fabric;
+use Faker\Factory as FakerFactory;
+use Faker\Generator;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class BatchTest
+ *
+ * @package Bitrix24\SDK\Tests\Integration\Services\Catalog\ProductProperty\Service
+ */
+#[CoversClass(\Bitrix24\SDK\Services\Catalog\ProductProperty\Service\Batch::class)]
+class BatchTest extends TestCase
+{
+ protected ProductProperty $productPropertyService;
+
+ protected Catalog $catalogService;
+
+ private Generator $faker;
+
+ private int $iblockId;
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[\Override]
+ protected function setUp(): void
+ {
+ $this->productPropertyService = Fabric::getServiceBuilder()->getCatalogScope()->productProperty();
+ $this->catalogService = Fabric::getServiceBuilder()->getCatalogScope()->catalog();
+ $this->faker = FakerFactory::create();
+ $this->iblockId = $this->catalogService->list([], [], [], 1)->getCatalogs()[0]->iblockId;
+ }
+
+ /**
+ * Helper: silently delete a product property by id.
+ */
+ private function safeDelete(int $id): void
+ {
+ try {
+ $this->productPropertyService->delete($id);
+ } catch (BaseException) {
+ // Server-side error; ignored during cleanup
+ }
+ }
+
+ /**
+ * Helper: silently batch-delete product properties by ids.
+ *
+ * @param int[] $ids
+ */
+ private function safeBatchDelete(array $ids): void
+ {
+ try {
+ foreach ($this->productPropertyService->batch->delete($ids) as $deleted) {
+ unset($deleted);
+ }
+ } catch (BaseException) {
+ // Server-side error; ignored during cleanup
+ }
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[TestDox('Batch list product properties')]
+ public function testBatchList(): void
+ {
+ $id = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_BATCH_LIST_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ ])->productProperty()->id;
+
+ $cnt = 0;
+ foreach ($this->productPropertyService->batch->list([], ['iblockId' => $this->iblockId]) as $item) {
+ $cnt++;
+ }
+
+ self::assertGreaterThanOrEqual(1, $cnt);
+
+ $this->safeDelete($id);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[TestDox('Batch add product properties')]
+ public function testBatchAdd(): void
+ {
+ $items = [];
+ for ($i = 1; $i <= 3; $i++) {
+ $items[] = [
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_BATCH_ADD_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ ];
+ }
+
+ $ids = [];
+ $cnt = 0;
+ foreach ($this->productPropertyService->batch->add($items) as $added) {
+ $cnt++;
+ $ids[] = $added->getId();
+ }
+
+ self::assertEquals(count($items), $cnt);
+
+ $this->safeBatchDelete($ids);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[TestDox('Batch update product properties')]
+ public function testBatchUpdate(): void
+ {
+ $ids = [];
+ for ($i = 1; $i <= 3; $i++) {
+ $ids[] = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_BATCH_UPD_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ ])->productProperty()->id;
+ }
+
+ $updatePayload = [];
+ foreach ($ids as $id) {
+ $updatePayload[$id] = [
+ 'fields' => [
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_BATCH_UPD_UPDATED_' . $this->faker->uuid(),
+ ],
+ ];
+ }
+
+ foreach ($this->productPropertyService->batch->update($updatePayload) as $updated) {
+ $this->assertTrue($updated->isSuccess());
+ }
+
+ $this->safeBatchDelete($ids);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[TestDox('Batch delete product properties')]
+ public function testBatchDelete(): void
+ {
+ $ids = [];
+ for ($i = 1; $i <= 3; $i++) {
+ $ids[] = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_BATCH_DEL_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ ])->productProperty()->id;
+ }
+
+ $delCnt = 0;
+ foreach ($this->productPropertyService->batch->delete($ids) as $deleted) {
+ $delCnt++;
+ }
+
+ self::assertEquals(count($ids), $delCnt);
+ }
+}
diff --git a/tests/Integration/Services/Catalog/ProductProperty/Service/ProductPropertyTest.php b/tests/Integration/Services/Catalog/ProductProperty/Service/ProductPropertyTest.php
new file mode 100644
index 00000000..35b43944
--- /dev/null
+++ b/tests/Integration/Services/Catalog/ProductProperty/Service/ProductPropertyTest.php
@@ -0,0 +1,188 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Tests\Integration\Services\Catalog\ProductProperty\Service;
+
+use Bitrix24\SDK\Core\Exceptions\BaseException;
+use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
+use Bitrix24\SDK\Core\Exceptions\TransportException;
+use Bitrix24\SDK\Services\Catalog\Catalog\Service\Catalog;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertyItemResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Service\ProductProperty;
+use Bitrix24\SDK\Tests\Integration\Fabric;
+use Faker\Factory as FakerFactory;
+use Faker\Generator;
+use PHPUnit\Framework\Attributes\CoversMethod;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class ProductPropertyTest
+ *
+ * @package Bitrix24\SDK\Tests\Integration\Services\Catalog\ProductProperty\Service
+ */
+#[CoversMethod(ProductProperty::class, 'add')]
+#[CoversMethod(ProductProperty::class, 'delete')]
+#[CoversMethod(ProductProperty::class, 'get')]
+#[CoversMethod(ProductProperty::class, 'list')]
+#[CoversMethod(ProductProperty::class, 'update')]
+#[CoversMethod(ProductProperty::class, 'getFields')]
+#[\PHPUnit\Framework\Attributes\CoversClass(ProductProperty::class)]
+class ProductPropertyTest extends TestCase
+{
+ private ProductProperty $productPropertyService;
+
+ private Catalog $catalogService;
+
+ private Generator $faker;
+
+ private int $iblockId;
+
+ /**
+ * @throws InvalidArgumentException
+ * @throws BaseException
+ * @throws TransportException
+ */
+ #[\Override]
+ protected function setUp(): void
+ {
+ $this->productPropertyService = Fabric::getServiceBuilder()->getCatalogScope()->productProperty();
+ $this->catalogService = Fabric::getServiceBuilder()->getCatalogScope()->catalog();
+ $this->faker = FakerFactory::create();
+ $this->iblockId = $this->catalogService->list([], [], [], 1)->getCatalogs()[0]->iblockId;
+ }
+
+ /**
+ * Helper: silently delete a product property.
+ */
+ private function safeDelete(int $id): void
+ {
+ try {
+ $this->productPropertyService->delete($id);
+ } catch (BaseException) {
+ // Server-side error; ignored during cleanup
+ }
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ public function testAdd(): void
+ {
+ $id = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_TEST_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ 'active' => 'Y',
+ ])->productProperty()->id;
+ self::assertGreaterThanOrEqual(1, $id);
+
+ $this->safeDelete($id);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ public function testGet(): void
+ {
+ $id = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_TEST_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ 'active' => 'Y',
+ ])->productProperty()->id;
+
+ $productPropertyItemResult = $this->productPropertyService->get($id)->productProperty();
+ self::assertInstanceOf(ProductPropertyItemResult::class, $productPropertyItemResult);
+ self::assertEquals($id, $productPropertyItemResult->id);
+
+ $this->safeDelete($id);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ public function testList(): void
+ {
+ $id = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_TEST_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ 'active' => 'Y',
+ ])->productProperty()->id;
+
+ $list = $this->productPropertyService->list([], ['id' => $id])->getProductProperties();
+ self::assertCount(1, $list);
+ self::assertEquals($id, $list[0]->id);
+
+ $this->safeDelete($id);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ public function testUpdate(): void
+ {
+ $id = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_TEST_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ 'active' => 'Y',
+ ])->productProperty()->id;
+
+ $updatedName = 'SDK_TEST_UPDATED_' . $this->faker->uuid();
+ $productPropertyItemResult = $this->productPropertyService->update($id, [
+ 'iblockId' => $this->iblockId,
+ 'name' => $updatedName,
+ ])->productProperty();
+ self::assertEquals($updatedName, $productPropertyItemResult->name);
+
+ $this->safeDelete($id);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ public function testDelete(): void
+ {
+ $id = $this->productPropertyService->add([
+ 'iblockId' => $this->iblockId,
+ 'name' => 'SDK_TEST_' . $this->faker->uuid(),
+ 'propertyType' => 'S',
+ 'active' => 'Y',
+ ])->productProperty()->id;
+
+ $deletedProductPropertyResult = $this->productPropertyService->delete($id);
+ self::assertTrue($deletedProductPropertyResult->isSuccess());
+
+ $list = $this->productPropertyService->list([], ['id' => $id])->getProductProperties();
+ self::assertCount(0, $list);
+ }
+
+ /**
+ * @throws BaseException
+ * @throws TransportException
+ */
+ public function testGetFields(): void
+ {
+ $fieldsDescription = $this->productPropertyService->getFields()->getFieldsDescription();
+ self::assertIsArray($fieldsDescription);
+ self::assertArrayHasKey('id', $fieldsDescription);
+ self::assertArrayHasKey('iblockId', $fieldsDescription);
+ self::assertArrayHasKey('propertyType', $fieldsDescription);
+ }
+}
diff --git a/tests/Unit/Catalog/ProductProperty/Service/ProductPropertyTest.php b/tests/Unit/Catalog/ProductProperty/Service/ProductPropertyTest.php
new file mode 100644
index 00000000..4c27a96b
--- /dev/null
+++ b/tests/Unit/Catalog/ProductProperty/Service/ProductPropertyTest.php
@@ -0,0 +1,179 @@
+
+ *
+ * For the full copyright and license information, please view the MIT-LICENSE.txt
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Bitrix24\SDK\Tests\Unit\Services\Catalog\ProductProperty\Service;
+
+use Bitrix24\SDK\Core\ApiLevelErrorHandler;
+use Bitrix24\SDK\Core\Commands\Command;
+use Bitrix24\SDK\Core\Contracts\CoreInterface;
+use Bitrix24\SDK\Core\Response\Response;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\DeletedProductPropertyResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertiesResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertyFieldsResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Result\ProductPropertyResult;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Service\Batch;
+use Bitrix24\SDK\Services\Catalog\ProductProperty\Service\ProductProperty;
+use Bitrix24\SDK\Tests\Unit\Stubs\NullBatch;
+use Bitrix24\SDK\Tests\Unit\Stubs\NullCore;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\HttpClient\Response\MockResponse;
+
+#[CoversClass(ProductProperty::class)]
+class ProductPropertyTest extends TestCase
+{
+ private ProductProperty $service;
+
+ #[\Override]
+ protected function setUp(): void
+ {
+ $this->service = new ProductProperty(
+ new Batch(new NullBatch(), new NullLogger()),
+ new NullCore(),
+ new NullLogger()
+ );
+ }
+
+ #[Test]
+ public function testAddReturnsProductPropertyResult(): void
+ {
+ $this->assertInstanceOf(
+ ProductPropertyResult::class,
+ $this->service->add(['iblockId' => 19, 'name' => 'Size', 'propertyType' => 'S'])
+ );
+ }
+
+ #[Test]
+ public function testUpdateReturnsProductPropertyResult(): void
+ {
+ $this->assertInstanceOf(
+ ProductPropertyResult::class,
+ $this->service->update(115, ['iblockId' => 19, 'name' => 'Size'])
+ );
+ }
+
+ #[Test]
+ public function testGetReturnsProductPropertyResult(): void
+ {
+ $this->assertInstanceOf(ProductPropertyResult::class, $this->service->get(115));
+ }
+
+ #[Test]
+ public function testListReturnsProductPropertiesResult(): void
+ {
+ $this->assertInstanceOf(ProductPropertiesResult::class, $this->service->list());
+ }
+
+ #[Test]
+ public function testDeleteReturnsDeletedProductPropertyResult(): void
+ {
+ $this->assertInstanceOf(DeletedProductPropertyResult::class, $this->service->delete(115));
+ }
+
+ #[Test]
+ public function testGetFieldsReturnsProductPropertyFieldsResult(): void
+ {
+ $this->assertInstanceOf(ProductPropertyFieldsResult::class, $this->service->getFields());
+ }
+
+ #[Test]
+ #[TestDox('add() sends fields nested under the fields key')]
+ public function testAddSendsNestedFields(): void
+ {
+ [$method, $captured] = $this->call(
+ static fn (ProductProperty $productProperty): ProductPropertyResult => $productProperty->add(['iblockId' => 19, 'name' => 'Size'])
+ );
+
+ $this->assertSame('catalog.productProperty.add', $method);
+ $this->assertSame(['iblockId' => 19, 'name' => 'Size'], $captured['fields']);
+ }
+
+ #[Test]
+ #[TestDox('update() sends id and nested fields')]
+ public function testUpdateSendsIdAndFields(): void
+ {
+ [$method, $captured] = $this->call(
+ static fn (ProductProperty $productProperty): ProductPropertyResult => $productProperty->update(115, ['iblockId' => 19, 'name' => 'Size'])
+ );
+
+ $this->assertSame('catalog.productProperty.update', $method);
+ $this->assertSame(115, $captured['id']);
+ $this->assertSame(['iblockId' => 19, 'name' => 'Size'], $captured['fields']);
+ }
+
+ #[Test]
+ #[TestDox('get() sends the property id')]
+ public function testGetSendsId(): void
+ {
+ [$method, $captured] = $this->call(static fn (ProductProperty $productProperty): ProductPropertyResult => $productProperty->get(115));
+
+ $this->assertSame('catalog.productProperty.get', $method);
+ $this->assertSame(115, $captured['id']);
+ }
+
+ #[Test]
+ #[TestDox('list() sends select, filter and order')]
+ public function testListSendsSelectFilterOrder(): void
+ {
+ [$method, $captured] = $this->call(
+ static fn (ProductProperty $productProperty): ProductPropertiesResult => $productProperty->list(['id', 'name'], ['iblockId' => 19], ['id' => 'ASC'])
+ );
+
+ $this->assertSame('catalog.productProperty.list', $method);
+ $this->assertSame(['id', 'name'], $captured['select']);
+ $this->assertSame(['iblockId' => 19], $captured['filter']);
+ $this->assertSame(['id' => 'ASC'], $captured['order']);
+ }
+
+ #[Test]
+ #[TestDox('delete() sends the property id')]
+ public function testDeleteSendsId(): void
+ {
+ [$method, $captured] = $this->call(static fn (ProductProperty $productProperty): DeletedProductPropertyResult => $productProperty->delete(115));
+
+ $this->assertSame('catalog.productProperty.delete', $method);
+ $this->assertSame(115, $captured['id']);
+ }
+
+ /**
+ * @return array{0: string, 1: array}
+ */
+ private function call(callable $action): array
+ {
+ $method = null;
+ $captured = [];
+ $response = new Response(
+ new MockResponse(''),
+ new Command('', []),
+ new ApiLevelErrorHandler(new NullLogger()),
+ new NullLogger()
+ );
+
+ $core = $this->createStub(CoreInterface::class);
+ $core->method('call')->willReturnCallback(
+ function (string $apiMethod, array $parameters = []) use (&$method, &$captured, $response): Response {
+ $method = $apiMethod;
+ $captured = $parameters;
+
+ return $response;
+ }
+ );
+
+ $action(new ProductProperty(new Batch(new NullBatch(), new NullLogger()), $core, new NullLogger()));
+
+ return [$method, $captured];
+ }
+}