diff --git a/.tasks/583/plan.md b/.tasks/583/plan.md new file mode 100644 index 00000000..b0145d89 --- /dev/null +++ b/.tasks/583/plan.md @@ -0,0 +1,477 @@ +# Plan: Add support for catalog.section.* (issue #583) + +## Context + +The Bitrix24 REST API exposes a `catalog.section.*` group for managing trade-catalog +sections (product categories inside an infoblock-backed catalog). This SDK does not yet +implement it. The issue lists six methods: + +- `catalog.section.add` +- `catalog.section.update` +- `catalog.section.get` +- `catalog.section.list` +- `catalog.section.delete` +- `catalog.section.getFields` + +Method details were fetched live via the Bitrix24 MCP docs tool +(`https://apidocs.bitrix24.com/api-reference/catalog/section/*`): + +### `catalog.section.add` +- param: `fields` (object, required) — `catalog_section` shape: + `iblockId` (int), `iblockSectionId` (int, parent, default = top level), + `name` (string, required), `xmlId` (string), `code` (string, must be unique), + `sort` (int, default 500), `active` (`Y`/`N`, default `Y`), + `description` (string), `descriptionType` (`text`|`html`) +- returns: `result.section` (full `catalog_section` object incl. `id`) + +### `catalog.section.update` +- params: `id` (int, required), `fields` (object, required) — same shape as add minus + requiredness (all optional on update) +- returns: `result.section` + +### `catalog.section.get` +- param: `id` (int, required) +- returns: `result.section` + +### `catalog.section.list` +- params: `select` (array, optional — all fields if omitted), `filter` (object, + **required**, must contain `iblockId`; prefix `>=`/`>` supported for numeric filters) +- returns: `result.sections` (array) — confirmed by symmetry with `catalog.document.list` + (`result.documents`) and `catalog.productPropertySection.list` + (`result.productPropertySections`); will be verified against the live raw response in + the annotation test. + +### `catalog.section.delete` +- param: `id` (int, required) +- returns: `result` (bool) + +### `catalog.section.getFields` +- no params +- returns: `result.section` = map of field code → `rest_field_description` + (`isImmutable`, `isReadOnly`, `isRequired`, `type`). Confirmed field set from live + doc example: + - `id`: integer, readOnly + - `iblockId`: integer, required + - `iblockSectionId`: integer + - `name`: string, required + - `xmlId`: string + - `code`: string + - `sort`: integer + - `active`: char (`Y`/`N`) + - `description`: string + - `descriptionType`: string + +### Key-naming difference vs. base `Core\Batch` + +`catalog.section.update` and `catalog.section.delete` use **lowercase** `id`, while the +base `Bitrix24\SDK\Core\Batch::deleteEntityItems()` registers deletes with uppercase +`ID`. This is the same situation already solved for `Services\Catalog\Document` — a +child `Batch` class overrides `determineKeyId()` and `deleteEntityItems()` to use +lowercase `id`. `updateEntityItems()` in the base class already builds `['id' => ..., +'fields' => ...]` (lowercase), so no override is needed there. + +`catalog.section.add` has no key-mapping concerns (`addEntityItems()` just forwards the +given `['fields' => ...]` item), so the base implementation is reused as-is. + +This SDK version targets **v3** (branch `v3-dev`, already checked out per user +instruction — no new branch/task-folder-only decisions needed from the user). + +Author attribution for all new files: `© Dmitriy Ignatenko ` +(per project convention seen in recently-added Catalog files). + +Reference implementations used as templates: +- `src/Services/Catalog/Document/*` — full pattern with custom `Batch` (lowercase `id`) +- `src/Services/Catalog/ProductPropertySection/*` — simple pattern, no batch + +--- + +## Files to Create + +### 1. `src/Services/Catalog/Section/Batch.php` + +Child of `\Bitrix24\SDK\Core\Batch`. Overrides `determineKeyId()` to return `'id'` and +`deleteEntityItems()` to register commands with `['id' => $itemId]` instead of +`['ID' => $itemId]`. Mirrors `src/Services/Catalog/Document/Batch.php` exactly, replacing +"document" wording with "section". + +### 2. `src/Services/Catalog/Section/Result/SectionItemResult.php` + +```php +namespace Bitrix24\SDK\Services\Catalog\Section\Result; + +use Bitrix24\SDK\Core\Result\AbstractAnnotatedItem; + +/** + * @property-read int $id + * @property-read int $iblockId + * @property-read int|null $iblockSectionId + * @property-read string $name + * @property-read string|null $xmlId + * @property-read string|null $code + * @property-read int|null $sort + * @property-read bool|null $active + * @property-read string|null $description + * @property-read string|null $descriptionType + */ +class SectionItemResult extends AbstractAnnotatedItem +{ +} +``` + +Generator note: this file matches the `*ItemResult.php` generator contract +(`src/Services/**/Result/*ItemResult.php` with `@property-read` annotations). The +generator (`b24-dev:result-item-generator catalog.section.get --stage=all`) requires a +live webhook call to `catalog.section.get`/`getFields` against a portal that already has +at least one section — since no section exists yet on the test portal and the exact +field set is already fully confirmed from the live MCP doc fetch (getFields example +above), the class is hand-written directly from that confirmed schema instead of running +the generator. This reason is recorded here per the generator-usage rule. + +### 3. `src/Services/Catalog/Section/Result/SectionResult.php` + +```php +namespace Bitrix24\SDK\Services\Catalog\Section\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class SectionResult extends AbstractResult +{ + /** @throws BaseException */ + public function section(): SectionItemResult + { + return new SectionItemResult($this->getCoreResponse()->getResponseData()->getResult()['section']); + } +} +``` + +### 4. `src/Services/Catalog/Section/Result/SectionsResult.php` + +```php +namespace Bitrix24\SDK\Services\Catalog\Section\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class SectionsResult extends AbstractResult +{ + /** + * @return SectionItemResult[] + * @throws BaseException + */ + public function getSections(): array + { + $result = $this->getCoreResponse()->getResponseData()->getResult(); + + return array_map( + static fn (array $item): SectionItemResult => new SectionItemResult($item), + $result['sections'] ?? [] + ); + } +} +``` + +### 5. `src/Services/Catalog/Section/Result/SectionFieldsResult.php` + +```php +namespace Bitrix24\SDK\Services\Catalog\Section\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class SectionFieldsResult extends AbstractResult +{ + /** + * @return array> + * @throws BaseException + */ + public function getFieldsDescription(): array + { + return $this->getCoreResponse()->getResponseData()->getResult()['section']; + } +} +``` + +### 6. `src/Services/Catalog/Section/Result/SectionAddedBatchResult.php` + +```php +namespace Bitrix24\SDK\Services\Catalog\Section\Result; + +use Bitrix24\SDK\Core\Response\DTO\ResponseData; + +class SectionAddedBatchResult +{ + public function __construct(private readonly ResponseData $responseData) + { + } + + public function getResponseData(): ResponseData + { + return $this->responseData; + } + + public function section(): SectionItemResult + { + return new SectionItemResult($this->responseData->getResult()['section']); + } +} +``` + +### 7. `src/Services/Catalog/Section/Result/SectionUpdatedBatchResult.php` + +Same shape as `SectionAddedBatchResult`, named `SectionUpdatedBatchResult`. + +### 8. `src/Services/Catalog/Section/Service/Section.php` + +```php +namespace Bitrix24\SDK\Services\Catalog\Section\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\Core\Result\DeletedItemResult; +use Bitrix24\SDK\Services\AbstractService; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionFieldsResult; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionResult; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionsResult; +use Psr\Log\LoggerInterface; + +#[ApiServiceMetadata(new Scope(['catalog']))] +class Section extends AbstractService +{ + public function __construct(public Batch $batch, CoreInterface $core, LoggerInterface $logger) + { + parent::__construct($core, $logger); + } + + #[ApiEndpointMetadata( + 'catalog.section.add', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-add.html', + 'Adds a new trade-catalog section' + )] + public function add(array $fields): SectionResult + { + return new SectionResult($this->core->call('catalog.section.add', ['fields' => $fields])); + } + + #[ApiEndpointMetadata( + 'catalog.section.update', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-update.html', + 'Updates a trade-catalog section by its identifier' + )] + public function update(int $id, array $fields): SectionResult + { + return new SectionResult($this->core->call('catalog.section.update', ['id' => $id, 'fields' => $fields])); + } + + #[ApiEndpointMetadata( + 'catalog.section.get', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-get.html', + 'Returns a trade-catalog section by its identifier' + )] + public function get(int $id): SectionResult + { + return new SectionResult($this->core->call('catalog.section.get', ['id' => $id])); + } + + #[ApiEndpointMetadata( + 'catalog.section.list', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-list.html', + 'Returns a list of trade-catalog sections by filter' + )] + public function list(array $select = [], array $filter = []): SectionsResult + { + return new SectionsResult( + $this->core->call('catalog.section.list', ['select' => $select, 'filter' => $filter]) + ); + } + + #[ApiEndpointMetadata( + 'catalog.section.delete', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-delete.html', + 'Deletes a trade-catalog section by identifier' + )] + public function delete(int $id): DeletedItemResult + { + return new DeletedItemResult($this->core->call('catalog.section.delete', ['id' => $id])); + } + + #[ApiEndpointMetadata( + 'catalog.section.getFields', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-get-fields.html', + 'Returns the description of trade-catalog section fields' + )] + public function getFields(): SectionFieldsResult + { + return new SectionFieldsResult($this->core->call('catalog.section.getFields')); + } +} +``` + +`filter` is required by the API (must contain `iblockId`) but is kept as a plain +optional-with-default `array $filter = []` parameter in the SDK signature — consistent +with how `Document::list()` and `ProductPropertySection::list()` expose `filter` (SDK +does not pre-validate required filter keys; the API itself returns the documented error +if `iblockId` is missing). + +### 9. `src/Services/Catalog/Section/Service/Batch.php` + +Mirrors `src/Services/Catalog/Document/Service/Batch.php`: `add()`, `update()`, +`delete()` generator wrappers around `Section\Batch::addEntityItems() / +updateEntityItems() / deleteEntityItems()`, yielding `SectionAddedBatchResult` / +`SectionUpdatedBatchResult` / `DeletedItemBatchResult`. + +### 10. `tests/Unit/Services/Catalog/Section/Service/SectionTest.php` + +Mirrors `tests/Unit/Services/Catalog/Document/Service/DocumentTest.php`: one test per +method (`add`, `update`, `get`, `list`, `delete`, `getFields`), asserting the exact +`core->call()` method name + parameters and the correct result wrapper class. + +### 11. `tests/Integration/Services/Catalog/Section/Service/SectionTest.php` + +Mirrors `tests/Integration/Services/Catalog/Document/Service/DocumentTest.php`: +- `setUp()`: get `Section` service via `Factory::getServiceBuilder(true)->getCatalogScope()->section()`; + resolve a real `iblockId` via `getCatalogScope()->catalog()->list([], [], [], 1)->getCatalogs()[0]->iblockId` + (same helper pattern as `Document`'s `createDocumentWithElement()`). +- `tearDown()`: delete any section ids created during the test. +- `testAddUpdateGetListDelete()`: add a section with `name` + `iblockId`, assert + returned fields, update `name`, assert via `get()`, assert presence via `list()` + filtered by `iblockId` + `id`, delete and assert `isSuccess()`. +- `testGetFields()`: assert `getFields()->getFieldsDescription()` is an array containing + the `iblockId` and `name` keys. + +### 12. `tests/Integration/Services/Catalog/Section/Service/BatchTest.php` + +Mirrors `tests/Integration/Services/Catalog/Document/Service/BatchTest.php`: +`testAddUpdateDelete()` using `$this->sectionService->batch->add/update/delete`. + +### 13. `tests/Integration/Services/Catalog/Section/Result/SectionItemResultTest.php` + +Follows the mandatory result-item annotation test template (see skill doc): +- `testAllFieldsAreAnnotated()` — fetch a raw section array via `list()` raw core + response and assert against `SectionItemResult::class`. +- `testAllFieldsHasValidTypeCastingInMagicGetters()` — fetch via `list()->getSections()[0]` + and assert type casts. + +--- + +## Files to Modify + +### 1. `src/Services/Catalog/CatalogServiceBuilder.php` + +Add, following the `document()` method pattern exactly: + +```php +public function section(): Catalog\Section\Service\Section +{ + if (!isset($this->serviceCache[__METHOD__])) { + $this->serviceCache[__METHOD__] = new Catalog\Section\Service\Section( + new Catalog\Section\Service\Batch( + new Catalog\Section\Batch($this->core, $this->log), + $this->log + ), + $this->core, + $this->log + ); + } + + return $this->serviceCache[__METHOD__]; +} +``` + +### 2. `phpunit.xml.dist` + +Add three testsuites after the `integration_tests_catalog_document_element_annotations` +block (line ~586), following the `Document`/`DocumentElement` block layout: + +```xml + + ./tests/Integration/Services/Catalog/Section/Service/ + + + ./tests/Integration/Services/Catalog/Section/Result/SectionItemResultTest.php + +``` + +(The `Service/` directory sweep already covers both `SectionTest.php` and +`BatchTest.php`, same as the `Document` block does.) + +### 3. `Makefile` + +Add after the `test-integration-catalog-document-element-annotations` target (~line 944), +following the exact `document`/`document-element` target style: + +```makefile +.PHONY: test-integration-catalog-section +test-integration-catalog-section: + docker compose run --rm php-cli $(PHPUNIT) --testsuite integration_tests_catalog_section +.PHONY: test-integration-catalog-section-annotations +test-integration-catalog-section-annotations: + docker compose run --rm php-cli $(PHPUNIT) --testsuite integration_tests_catalog_section_annotations +``` + +Also add a row to the Catalog integration-tests reference table in `docs/testing.md` +(`make test-integration-catalog-section` / `make test-integration-catalog-section-annotations`). + +### 4. `docs/testing.md` + +Add two rows to the "Tests — integration (Catalog)" table: + +```markdown +| `make test-integration-catalog-section` | Trade-catalog sections | +| `make test-integration-catalog-section-annotations` | Trade-catalog section result annotations | +``` + +### 5. `CHANGELOG.md` + +Add under `## Unreleased` → `### Added`, above the existing `Services\Catalog\Document` +entry (newest entries go first per convention): + +```markdown +- Added service `Services\Catalog\Section` with support methods, + see [catalog.section.* methods](https://apidocs.bitrix24.com/api-reference/catalog/section/index.html) ([#583](https://github.com/bitrix24/b24phpsdk/issues/583)): + - `add` creates a new trade-catalog section, with batch calls support + - `update` updates an existing trade-catalog section, with batch calls support + - `get` gets a trade-catalog section by its identifier + - `list` gets the list of trade-catalog sections by filter + - `delete` deletes a trade-catalog section, with batch calls support + - `getFields` returns the description of trade-catalog section fields +``` + +### 6. `.php-cs-fixer.php`, `phpstan.neon.dist`, `rector.php` + +**No changes** — confirmed by direct inspection: +- `.php-cs-fixer.php` already includes the whole `src/Services/Catalog/` directory + (line 15), so `Section` is covered automatically. +- `phpstan.neon.dist` already includes plain `src/` (whole tree) plus the whole + `tests/Integration/Services/Catalog` directory (line 10). +- `rector.php` already includes the whole `src/Services/Catalog` and + `tests/Integration/Services/Catalog` directories (lines 23–24). + +No new entries needed in any of the three files for this issue. + +--- + +## Deptrac compliance + +`Section` lives entirely under `Services/Catalog`, importing only from `Core` (via +`AbstractService`, `AbstractAnnotatedItem`, `AbstractResult`, exceptions, `Batch`) — same +dependency shape as `Document`. No new Deptrac violation is expected; no +`skip_violations` entry needed. + +--- + +## Verification + +```bash +make lint-cs-fixer +make lint-rector +make lint-phpstan +make lint-deptrac +make test-unit +make test-integration-catalog-section +make test-integration-catalog-section-annotations +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9e0b62..23f1e48d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,22 @@ - `list` gets the list of contractor bindings by filter - `delete` deletes a contractor binding, with batch calls support - `getFields` returns the description of contractor binding fields +- Added service `Services\Catalog\Section` with support methods, + see [catalog.section.* methods](https://apidocs.bitrix24.com/api-reference/catalog/section/index.html) ([#583](https://github.com/bitrix24/b24phpsdk/issues/583)): + - `add` creates a new trade-catalog section, with batch calls support + - `update` updates an existing trade-catalog section, with batch calls support + - `get` gets a trade-catalog section by its identifier + - `list` gets the list of trade-catalog sections by filter + - `delete` deletes a trade-catalog section, with batch calls support + - `getFields` returns the description of trade-catalog section fields +- Added service `Services\Catalog\RoundingRule` with support methods, + see [catalog.roundingRule.* methods](https://apidocs.bitrix24.com/api-reference/catalog/rounding-rule/index.html) ([#573](https://github.com/bitrix24/b24phpsdk/issues/573)): + - `add` creates a new price rounding rule, with batch calls support + - `update` updates an existing price rounding rule, with batch calls support + - `list` gets the list of price rounding rules + - `delete` deletes a price rounding rule, with batch calls support + - `get` gets information about a price rounding rule by its identifier + - `getFields` returns the description of price rounding rule fields ## 3.5.0 diff --git a/Makefile b/Makefile index 140ebb05..8e4a045b 100644 --- a/Makefile +++ b/Makefile @@ -944,6 +944,12 @@ test-integration-catalog-document-element: test-integration-catalog-document-element-annotations: docker compose run --rm php-cli $(PHPUNIT) --testsuite integration_tests_catalog_document_element_annotations +.PHONY: test-integration-catalog-section +test-integration-catalog-section: + docker compose run --rm php-cli $(PHPUNIT) --testsuite integration_tests_catalog_section +.PHONY: test-integration-catalog-section-annotations +test-integration-catalog-section-annotations: + docker compose run --rm php-cli $(PHPUNIT) --testsuite integration_tests_catalog_section_annotations .PHONY: test-integration-catalog-rounding-rule test-integration-catalog-rounding-rule: docker compose run --rm php-cli $(PHPUNIT) --testsuite integration_tests_catalog_rounding_rule diff --git a/docs/testing.md b/docs/testing.md index 0566ad5c..130deda2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -302,6 +302,8 @@ skip_violations: | `make test-integration-catalog-document-annotations` | Warehouse accounting document result annotations | | `make test-integration-catalog-document-element` | Warehouse accounting document line items | | `make test-integration-catalog-document-element-annotations` | Warehouse accounting document line item result annotations | +| `make test-integration-catalog-section` | Trade-catalog sections | +| `make test-integration-catalog-section-annotations` | Trade-catalog section result annotations | | `make test-integration-catalog-rounding-rule` | Price rounding rules | | `make test-integration-catalog-ratio` | Measurement unit ratio | diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5ab9d7e5..4b63b937 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -584,6 +584,11 @@ ./tests/Integration/Services/Catalog/DocumentElement/Result/DocumentElementItemResultTest.php + + ./tests/Integration/Services/Catalog/Section/Service/ + + + ./tests/Integration/Services/Catalog/Section/Result/SectionItemResultTest.php ./tests/Integration/Services/Catalog/RoundingRule/ diff --git a/src/Services/Catalog/CatalogServiceBuilder.php b/src/Services/Catalog/CatalogServiceBuilder.php index 30dcf2c1..1983336d 100644 --- a/src/Services/Catalog/CatalogServiceBuilder.php +++ b/src/Services/Catalog/CatalogServiceBuilder.php @@ -291,6 +291,12 @@ public function documentElement(): Catalog\DocumentElement\Service\DocumentEleme return $this->serviceCache[__METHOD__]; } + public function section(): Catalog\Section\Service\Section + { + if (!isset($this->serviceCache[__METHOD__])) { + $this->serviceCache[__METHOD__] = new Catalog\Section\Service\Section( + new Catalog\Section\Service\Batch( + new Catalog\Section\Batch($this->core, $this->log), public function ratio(): Catalog\Ratio\Service\Ratio { if (!isset($this->serviceCache[__METHOD__])) { diff --git a/src/Services/Catalog/Section/Batch.php b/src/Services/Catalog/Section/Batch.php new file mode 100644 index 00000000..b257245e --- /dev/null +++ b/src/Services/Catalog/Section/Batch.php @@ -0,0 +1,108 @@ + + * + * 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\Section; + +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 parameter naming differences in catalog.section.* REST methods: + * - delete uses lowercase 'id' instead of 'ID' + * + * @see https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-delete.html + * @see https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-list.html + */ +class Batch extends \Bitrix24\SDK\Core\Batch +{ + /** + * Determines the ID key — lowercase 'id' for catalog section + */ + #[\Override] + protected function determineKeyId(string $apiMethod, ?array $additionalParameters): string + { + return 'id'; + } + + /** + * Delete entity items with batch call using lowercase 'id' parameter + * + * @param int[] $entityItemId + * @param array|null $additionalParameters + * + * @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 section id «%s» at position %s, section 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 section items: %s', $exception->getMessage()); + $this->logger->error( + $errorMessage, + [ + 'trace' => $exception->getTrace(), + ] + ); + throw $exception; + } catch (\Throwable $exception) { + $errorMessage = sprintf('batch delete section 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/Section/Result/SectionAddedBatchResult.php b/src/Services/Catalog/Section/Result/SectionAddedBatchResult.php new file mode 100644 index 00000000..7f7a5876 --- /dev/null +++ b/src/Services/Catalog/Section/Result/SectionAddedBatchResult.php @@ -0,0 +1,33 @@ + + * + * 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\Section\Result; + +use Bitrix24\SDK\Core\Response\DTO\ResponseData; + +class SectionAddedBatchResult +{ + public function __construct(private readonly ResponseData $responseData) + { + } + + public function getResponseData(): ResponseData + { + return $this->responseData; + } + + public function section(): SectionItemResult + { + return new SectionItemResult($this->responseData->getResult()['section']); + } +} diff --git a/src/Services/Catalog/Section/Result/SectionFieldsResult.php b/src/Services/Catalog/Section/Result/SectionFieldsResult.php new file mode 100644 index 00000000..48b1f0ab --- /dev/null +++ b/src/Services/Catalog/Section/Result/SectionFieldsResult.php @@ -0,0 +1,29 @@ + + * + * 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\Section\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class SectionFieldsResult extends AbstractResult +{ + /** + * @return array> + * @throws BaseException + */ + public function getFieldsDescription(): array + { + return $this->getCoreResponse()->getResponseData()->getResult()['section']; + } +} diff --git a/src/Services/Catalog/Section/Result/SectionItemResult.php b/src/Services/Catalog/Section/Result/SectionItemResult.php new file mode 100644 index 00000000..c8c17b94 --- /dev/null +++ b/src/Services/Catalog/Section/Result/SectionItemResult.php @@ -0,0 +1,32 @@ + + * + * 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\Section\Result; + +use Bitrix24\SDK\Core\Result\AbstractAnnotatedItem; + +/** + * @property-read int $id + * @property-read int $iblockId + * @property-read int|null $iblockSectionId + * @property-read string $name + * @property-read string|null $xmlId + * @property-read string|null $code + * @property-read int|null $sort + * @property-read bool|null $active + * @property-read string|null $description + * @property-read string|null $descriptionType + */ +class SectionItemResult extends AbstractAnnotatedItem +{ +} diff --git a/src/Services/Catalog/Section/Result/SectionResult.php b/src/Services/Catalog/Section/Result/SectionResult.php new file mode 100644 index 00000000..0df7a328 --- /dev/null +++ b/src/Services/Catalog/Section/Result/SectionResult.php @@ -0,0 +1,28 @@ + + * + * 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\Section\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class SectionResult extends AbstractResult +{ + /** + * @throws BaseException + */ + public function section(): SectionItemResult + { + return new SectionItemResult($this->getCoreResponse()->getResponseData()->getResult()['section']); + } +} diff --git a/src/Services/Catalog/Section/Result/SectionUpdatedBatchResult.php b/src/Services/Catalog/Section/Result/SectionUpdatedBatchResult.php new file mode 100644 index 00000000..f381da3d --- /dev/null +++ b/src/Services/Catalog/Section/Result/SectionUpdatedBatchResult.php @@ -0,0 +1,33 @@ + + * + * 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\Section\Result; + +use Bitrix24\SDK\Core\Response\DTO\ResponseData; + +class SectionUpdatedBatchResult +{ + public function __construct(private readonly ResponseData $responseData) + { + } + + public function getResponseData(): ResponseData + { + return $this->responseData; + } + + public function section(): SectionItemResult + { + return new SectionItemResult($this->responseData->getResult()['section']); + } +} diff --git a/src/Services/Catalog/Section/Result/SectionsResult.php b/src/Services/Catalog/Section/Result/SectionsResult.php new file mode 100644 index 00000000..d3290dc2 --- /dev/null +++ b/src/Services/Catalog/Section/Result/SectionsResult.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\Section\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class SectionsResult extends AbstractResult +{ + /** + * @return SectionItemResult[] + * @throws BaseException + */ + public function getSections(): array + { + $result = $this->getCoreResponse()->getResponseData()->getResult(); + + return array_map( + static fn (array $item): SectionItemResult => new SectionItemResult($item), + $result['sections'] ?? [] + ); + } +} diff --git a/src/Services/Catalog/Section/Service/Batch.php b/src/Services/Catalog/Section/Service/Batch.php new file mode 100644 index 00000000..0f0f60c8 --- /dev/null +++ b/src/Services/Catalog/Section/Service/Batch.php @@ -0,0 +1,103 @@ + + * + * 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\Section\Service; + +use Bitrix24\SDK\Attributes\ApiBatchMethodMetadata; +use Bitrix24\SDK\Attributes\ApiBatchServiceMetadata; +use Bitrix24\SDK\Core\Credentials\Scope; +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\DeletedItemBatchResult; +use Bitrix24\SDK\Services\Catalog\Section; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionAddedBatchResult; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionUpdatedBatchResult; +use Generator; +use Psr\Log\LoggerInterface; + +#[ApiBatchServiceMetadata(new Scope(['catalog']))] +class Batch +{ + public function __construct(protected Section\Batch $batch, protected LoggerInterface $log) + { + } + + /** + * Batch adding trade-catalog sections + * + * @param array $sections + * + * @return Generator + * @throws BaseException + */ + #[ApiBatchMethodMetadata( + 'catalog.section.add', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-add.html', + 'Batch adding trade-catalog sections' + )] + public function add(array $sections): Generator + { + $items = []; + foreach ($sections as $section) { + $items[] = ['fields' => $section]; + } + + foreach ($this->batch->addEntityItems('catalog.section.add', $items) as $key => $item) { + yield $key => new SectionAddedBatchResult($item); + } + } + + /** + * Batch delete trade-catalog sections + * + * @param int[] $sectionId + * + * @return Generator + * @throws BaseException + */ + #[ApiBatchMethodMetadata( + 'catalog.section.delete', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-delete.html', + 'Batch delete trade-catalog sections' + )] + public function delete(array $sectionId): Generator + { + foreach ($this->batch->deleteEntityItems('catalog.section.delete', $sectionId) as $key => $item) { + yield $key => new DeletedItemBatchResult($item); + } + } + + /** + * Batch update trade-catalog sections + * + * @param array $sections keyed by section id + * + * @return Generator + * @throws BaseException + */ + #[ApiBatchMethodMetadata( + 'catalog.section.update', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-update.html', + 'Batch update trade-catalog sections' + )] + public function update(array $sections): Generator + { + $items = []; + foreach ($sections as $id => $section) { + $items[$id] = ['fields' => $section]; + } + + foreach ($this->batch->updateEntityItems('catalog.section.update', $items) as $key => $item) { + yield $key => new SectionUpdatedBatchResult($item); + } + } +} diff --git a/src/Services/Catalog/Section/Service/Section.php b/src/Services/Catalog/Section/Service/Section.php new file mode 100644 index 00000000..f5d0a59e --- /dev/null +++ b/src/Services/Catalog/Section/Service/Section.php @@ -0,0 +1,152 @@ + + * + * 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\Section\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\Core\Result\DeletedItemResult; +use Bitrix24\SDK\Services\AbstractService; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionFieldsResult; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionResult; +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionsResult; +use Psr\Log\LoggerInterface; + +#[ApiServiceMetadata(new Scope(['catalog']))] +class Section extends AbstractService +{ + public function __construct(public Batch $batch, CoreInterface $core, LoggerInterface $logger) + { + parent::__construct($core, $logger); + } + + /** + * Adds a new trade-catalog section + * + * @link https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-add.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'catalog.section.add', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-add.html', + 'Adds a new trade-catalog section' + )] + public function add(array $fields): SectionResult + { + return new SectionResult($this->core->call('catalog.section.add', ['fields' => $fields])); + } + + /** + * Updates a trade-catalog section by its identifier + * + * Note: despite the API documentation listing `iblockId` as optional on update, the live + * API rejects the call with "Required fields: iblockId" if it is omitted from $fields. + * + * @link https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-update.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'catalog.section.update', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-update.html', + 'Updates a trade-catalog section by its identifier' + )] + public function update(int $id, array $fields): SectionResult + { + return new SectionResult($this->core->call('catalog.section.update', ['id' => $id, 'fields' => $fields])); + } + + /** + * Returns a trade-catalog section by its identifier + * + * @link https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-get.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'catalog.section.get', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-get.html', + 'Returns a trade-catalog section by its identifier' + )] + public function get(int $id): SectionResult + { + return new SectionResult($this->core->call('catalog.section.get', ['id' => $id])); + } + + /** + * Returns a list of trade-catalog sections by filter + * + * @link https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-list.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'catalog.section.list', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-list.html', + 'Returns a list of trade-catalog sections by filter' + )] + public function list(array $select = [], array $filter = []): SectionsResult + { + return new SectionsResult( + $this->core->call( + 'catalog.section.list', + ['select' => $select, 'filter' => $filter] + ) + ); + } + + /** + * Deletes a trade-catalog section by identifier + * + * @link https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-delete.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'catalog.section.delete', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-delete.html', + 'Deletes a trade-catalog section by identifier' + )] + public function delete(int $id): DeletedItemResult + { + return new DeletedItemResult($this->core->call('catalog.section.delete', ['id' => $id])); + } + + /** + * Returns the fields of a trade-catalog section + * + * @link https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-get-fields.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'catalog.section.getFields', + 'https://apidocs.bitrix24.com/api-reference/catalog/section/catalog-section-get-fields.html', + 'Returns the description of trade-catalog section fields' + )] + public function getFields(): SectionFieldsResult + { + return new SectionFieldsResult($this->core->call('catalog.section.getFields')); + } +} diff --git a/tests/Integration/Services/Catalog/Section/Result/SectionItemResultTest.php b/tests/Integration/Services/Catalog/Section/Result/SectionItemResultTest.php new file mode 100644 index 00000000..3b3439a9 --- /dev/null +++ b/tests/Integration/Services/Catalog/Section/Result/SectionItemResultTest.php @@ -0,0 +1,70 @@ + + * + * 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\Section\Result; + +use Bitrix24\SDK\Services\Catalog\Section\Result\SectionItemResult; +use Bitrix24\SDK\Services\Catalog\Section\Service\Section; +use Bitrix24\SDK\Tests\CustomAssertions\CustomBitrix24Assertions; +use Bitrix24\SDK\Tests\Integration\Factory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(SectionItemResult::class)] +class SectionItemResultTest extends TestCase +{ + use CustomBitrix24Assertions; + + private Section $sectionService; + + private int $sectionId; + + #[\Override] + protected function setUp(): void + { + $serviceBuilder = Factory::getServiceBuilder(true); + $this->sectionService = $serviceBuilder->getCatalogScope()->section(); + $iblockId = $serviceBuilder->getCatalogScope()->catalog() + ->list([], [], [], 1)->getCatalogs()[0]->iblockId; + + $this->sectionId = $this->sectionService->add([ + 'name' => sprintf('test section annotations %s', time()), + 'iblockId' => $iblockId, + ])->section()->id; + } + + #[\Override] + protected function tearDown(): void + { + $this->sectionService->delete($this->sectionId); + } + + #[Test] + #[TestDox('all fields in SectionItemResult are annotated in phpdoc and match with raw api response')] + public function testAllFieldsAreAnnotated(): void + { + $rawItem = $this->sectionService->get($this->sectionId) + ->getCoreResponse()->getResponseData()->getResult()['section']; + $this->assertBitrix24AllResultItemFieldsAnnotated(array_keys($rawItem), SectionItemResult::class); + } + + #[Test] + #[TestDox('all fields in SectionItemResult have valid type casting in magic getters')] + public function testAllFieldsHasValidTypeCastingInMagicGetters(): void + { + $sectionItemResult = $this->sectionService->get($this->sectionId)->section(); + $this->assertBitrix24ResultItemFieldsTypeCastMatchAnnotations($sectionItemResult, SectionItemResult::class); + } +} diff --git a/tests/Integration/Services/Catalog/Section/Service/BatchTest.php b/tests/Integration/Services/Catalog/Section/Service/BatchTest.php new file mode 100644 index 00000000..bc4556bf --- /dev/null +++ b/tests/Integration/Services/Catalog/Section/Service/BatchTest.php @@ -0,0 +1,78 @@ + + * + * 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\Section\Service; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Exceptions\TransportException; +use Bitrix24\SDK\Services\Catalog\Section\Service\Batch; +use Bitrix24\SDK\Services\Catalog\Section\Service\Section; +use Bitrix24\SDK\Tests\Integration\Factory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Batch::class)] +class BatchTest extends TestCase +{ + private Section $sectionService; + + private int $iblockId; + + #[\Override] + protected function setUp(): void + { + $serviceBuilder = Factory::getServiceBuilder(true); + $this->sectionService = $serviceBuilder->getCatalogScope()->section(); + $this->iblockId = $serviceBuilder->getCatalogScope()->catalog() + ->list([], [], [], 1)->getCatalogs()[0]->iblockId; + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('test Batch::add, Batch::update, Batch::delete')] + public function testAddUpdateDelete(): void + { + $addedIds = []; + foreach ($this->sectionService->batch->add([ + ['name' => sprintf('batch section %s', time()), 'iblockId' => $this->iblockId], + ]) as $addedItemResult) { + $addedIds[] = $addedItemResult->section()->id; + } + + $this->assertCount(1, $addedIds); + + $updatePayload = []; + foreach ($addedIds as $id) { + $updatePayload[$id] = ['name' => 'updated batch section', 'iblockId' => $this->iblockId]; + } + + $updatedCount = 0; + foreach ($this->sectionService->batch->update($updatePayload) as $updatedItemResult) { + $this->assertSame('updated batch section', $updatedItemResult->section()->name); + $updatedCount++; + } + + $this->assertSame(1, $updatedCount); + + $deletedCount = 0; + foreach ($this->sectionService->batch->delete($addedIds) as $deletedItemResult) { + $this->assertTrue($deletedItemResult->isSuccess()); + $deletedCount++; + } + + $this->assertSame(1, $deletedCount); + } +} diff --git a/tests/Integration/Services/Catalog/Section/Service/SectionTest.php b/tests/Integration/Services/Catalog/Section/Service/SectionTest.php new file mode 100644 index 00000000..b83dcfd0 --- /dev/null +++ b/tests/Integration/Services/Catalog/Section/Service/SectionTest.php @@ -0,0 +1,101 @@ + + * + * 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\Section\Service; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Exceptions\TransportException; +use Bitrix24\SDK\Services\Catalog\Section\Service\Section; +use Bitrix24\SDK\Tests\Integration\Factory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Section::class)] +class SectionTest extends TestCase +{ + private Section $sectionService; + + private int $iblockId; + + /** + * @var int[] + */ + private array $createdSectionIds = []; + + #[\Override] + protected function setUp(): void + { + $serviceBuilder = Factory::getServiceBuilder(true); + $this->sectionService = $serviceBuilder->getCatalogScope()->section(); + $this->iblockId = $serviceBuilder->getCatalogScope()->catalog() + ->list([], [], [], 1)->getCatalogs()[0]->iblockId; + } + + #[\Override] + protected function tearDown(): void + { + foreach ($this->createdSectionIds as $sectionId) { + try { + $this->sectionService->delete($sectionId); + } catch (\Throwable) { + // already removed, ignore + } + } + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('test Section::add, Section::update, Section::get, Section::list, Section::delete')] + public function testAddUpdateGetListDelete(): void + { + $name = sprintf('test section %s', time()); + $addResult = $this->sectionService->add([ + 'name' => $name, + 'iblockId' => $this->iblockId, + ]); + $sectionId = $addResult->section()->id; + $this->createdSectionIds[] = $sectionId; + + $this->assertSame($name, $addResult->section()->name); + $this->assertSame($this->iblockId, $addResult->section()->iblockId); + + $updatedName = sprintf('updated test section %s', time()); + $updateResult = $this->sectionService->update($sectionId, ['name' => $updatedName, 'iblockId' => $this->iblockId]); + $this->assertSame($updatedName, $updateResult->section()->name); + + $getResult = $this->sectionService->get($sectionId); + $this->assertSame($updatedName, $getResult->section()->name); + + $listResult = $this->sectionService->list([], ['iblockId' => $this->iblockId, 'id' => $sectionId]); + $this->assertCount(1, $listResult->getSections()); + + $this->assertTrue($this->sectionService->delete($sectionId)->isSuccess()); + $this->createdSectionIds = array_diff($this->createdSectionIds, [$sectionId]); + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('test Section::getFields')] + public function testGetFields(): void + { + $fieldsDescription = $this->sectionService->getFields()->getFieldsDescription(); + $this->assertIsArray($fieldsDescription); + $this->assertArrayHasKey('iblockId', $fieldsDescription); + $this->assertArrayHasKey('name', $fieldsDescription); + } +} diff --git a/tests/Unit/Services/Catalog/Section/Service/SectionTest.php b/tests/Unit/Services/Catalog/Section/Service/SectionTest.php new file mode 100644 index 00000000..cb3c1b1f --- /dev/null +++ b/tests/Unit/Services/Catalog/Section/Service/SectionTest.php @@ -0,0 +1,98 @@ +mockCore('catalog.section.add', [ + 'fields' => ['name' => 'Kids Toys', 'iblockId' => 14], + ]); + + self::assertInstanceOf( + SectionResult::class, + $this->makeService($core)->add(['name' => 'Kids Toys', 'iblockId' => 14]) + ); + } + + public function testUpdateBuildsParameters(): void + { + $core = $this->mockCore('catalog.section.update', [ + 'id' => 32, + 'fields' => ['name' => 'Updated name'], + ]); + + self::assertInstanceOf( + SectionResult::class, + $this->makeService($core)->update(32, ['name' => 'Updated name']) + ); + } + + public function testGetBuildsParameters(): void + { + $core = $this->mockCore('catalog.section.get', ['id' => 31]); + + self::assertInstanceOf(SectionResult::class, $this->makeService($core)->get(31)); + } + + public function testListBuildsParameters(): void + { + $core = $this->mockCore('catalog.section.list', [ + 'select' => ['id', 'name'], + 'filter' => ['iblockId' => 14], + ]); + + self::assertInstanceOf( + SectionsResult::class, + $this->makeService($core)->list(['id', 'name'], ['iblockId' => 14]) + ); + } + + public function testDeleteBuildsParameters(): void + { + $core = $this->mockCore('catalog.section.delete', ['id' => 31]); + + self::assertInstanceOf(DeletedItemResult::class, $this->makeService($core)->delete(31)); + } + + public function testGetFieldsBuildsParameters(): void + { + $core = $this->mockCore('catalog.section.getFields', []); + + self::assertInstanceOf(SectionFieldsResult::class, $this->makeService($core)->getFields()); + } + + private function makeService(CoreInterface $core): Section + { + return new Section(new Batch(new SectionBatch($core, new NullLogger()), new NullLogger()), $core, new NullLogger()); + } + + private function mockCore(string $method, array $parameters): CoreInterface + { + $response = $this->createStub(Response::class); + $core = $this->createMock(CoreInterface::class); + $core->expects($this->once()) + ->method('call') + ->with($method, $parameters) + ->willReturn($response); + + return $core; + } +}