From 043e6238a5e4168abe201f1484c42804a28293e3 Mon Sep 17 00:00:00 2001 From: mesilov Date: Tue, 14 Oct 2025 02:55:33 +0600 Subject: [PATCH 01/33] Add support for `crm.type` API in SDK, including methods for managing smart process automation types and updating relevant tests. Signed-off-by: mesilov --- src/Services/CRM/CRMServiceBuilder.php | 12 ++ .../CRM/Type/Result/AddedTypeItemResult.php | 34 ++++ .../CRM/Type/Result/DeletedItemResult.php | 26 +++ .../CRM/Type/Result/TypeItemResult.php | 53 ++++++ src/Services/CRM/Type/Result/TypeResult.php | 25 +++ src/Services/CRM/Type/Result/TypesResult.php | 35 ++++ src/Services/CRM/Type/Service/Type.php | 165 ++++++++++++++++++ .../CustomBitrix24Assertions.php | 39 +++-- 8 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 src/Services/CRM/Type/Result/AddedTypeItemResult.php create mode 100644 src/Services/CRM/Type/Result/DeletedItemResult.php create mode 100644 src/Services/CRM/Type/Result/TypeItemResult.php create mode 100644 src/Services/CRM/Type/Result/TypeResult.php create mode 100644 src/Services/CRM/Type/Result/TypesResult.php create mode 100644 src/Services/CRM/Type/Service/Type.php diff --git a/src/Services/CRM/CRMServiceBuilder.php b/src/Services/CRM/CRMServiceBuilder.php index b4905fc3..9ed73512 100644 --- a/src/Services/CRM/CRMServiceBuilder.php +++ b/src/Services/CRM/CRMServiceBuilder.php @@ -671,4 +671,16 @@ public function documentgeneratorNumerator(): Documentgenerator\Numerator\Servic return $this->serviceCache[__METHOD__]; } + + public function type(): Type\Service\Type + { + if (!isset($this->serviceCache[__METHOD__])) { + $this->serviceCache[__METHOD__] = new Type\Service\Type( + $this->core, + $this->log + ); + } + + return $this->serviceCache[__METHOD__]; + } } diff --git a/src/Services/CRM/Type/Result/AddedTypeItemResult.php b/src/Services/CRM/Type/Result/AddedTypeItemResult.php new file mode 100644 index 00000000..7f54edef --- /dev/null +++ b/src/Services/CRM/Type/Result/AddedTypeItemResult.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\CRM\Type\Result; + +use Bitrix24\SDK\Core\Contracts\AddedItemIdResultInterface; +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class AddedTypeItemResult extends AbstractResult implements AddedItemIdResultInterface +{ + public function type(): TypeItemResult + { + return new TypeItemResult($this->getCoreResponse()->getResponseData()->getResult()['type']); + } + + /** + * @throws BaseException + */ + public function getId(): int + { + return (int)$this->getCoreResponse()->getResponseData()->getResult()['type']['id']; + } +} \ No newline at end of file diff --git a/src/Services/CRM/Type/Result/DeletedItemResult.php b/src/Services/CRM/Type/Result/DeletedItemResult.php new file mode 100644 index 00000000..944fc0d8 --- /dev/null +++ b/src/Services/CRM/Type/Result/DeletedItemResult.php @@ -0,0 +1,26 @@ + + * + * 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\CRM\Type\Result; + +use Bitrix24\SDK\Core\Contracts\DeletedItemResultInterface; +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class DeletedItemResult extends AbstractResult implements DeletedItemResultInterface +{ + public function isSuccess(): bool + { + return true; + } +} \ No newline at end of file diff --git a/src/Services/CRM/Type/Result/TypeItemResult.php b/src/Services/CRM/Type/Result/TypeItemResult.php new file mode 100644 index 00000000..3be0453b --- /dev/null +++ b/src/Services/CRM/Type/Result/TypeItemResult.php @@ -0,0 +1,53 @@ + + * + * 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\CRM\Type\Result; + +use Bitrix24\SDK\Core\Result\AbstractItem; +use Carbon\CarbonImmutable; + +/** + * @property-read non-negative-int $id + * @property-read string $title + * @property-read string $code + * @property-read non-negative-int $createdBy + * @property-read non-negative-int $entityTypeId + * @property-read non-negative-int|null $customSectionId + * @property-read bool $isCategoriesEnabled + * @property-read bool $isStagesEnabled + * @property-read bool $isBeginCloseDatesEnabled + * @property-read bool $isClientEnabled + * @property-read bool $isUseInUserfieldEnabled + * @property-read bool $isLinkWithProductsEnabled + * @property-read bool $isMycompanyEnabled + * @property-read bool $isDocumentsEnabled + * @property-read bool $isSourceEnabled + * @property-read bool $isObserversEnabled + * @property-read bool $isRecurringEnabled + * @property-read bool $isRecyclebinEnabled + * @property-read bool $isAutomationEnabled + * @property-read bool $isBizProcEnabled + * @property-read bool $isSetOpenPermissions + * @property-read bool $isPaymentsEnabled + * @property-read bool $isCountersEnabled + * @property-read CarbonImmutable $createdTime + * @property-read CarbonImmutable $updatedTime + * @property-read int $updatedBy + * @property-read bool $isInitialized + * @property-read array $relations + * @property-read array $linkedUserFields + * @property-read array $customSections + */ +class TypeItemResult extends AbstractItem +{ +} diff --git a/src/Services/CRM/Type/Result/TypeResult.php b/src/Services/CRM/Type/Result/TypeResult.php new file mode 100644 index 00000000..aa212d6b --- /dev/null +++ b/src/Services/CRM/Type/Result/TypeResult.php @@ -0,0 +1,25 @@ + + * + * 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\CRM\Type\Result; + +use Bitrix24\SDK\Core\Result\AbstractResult; + +class TypeResult extends AbstractResult +{ + public function type(): TypeItemResult + { + return new TypeItemResult($this->getCoreResponse()->getResponseData()->getResult()['type']); + } +} diff --git a/src/Services/CRM/Type/Result/TypesResult.php b/src/Services/CRM/Type/Result/TypesResult.php new file mode 100644 index 00000000..b3ada5a0 --- /dev/null +++ b/src/Services/CRM/Type/Result/TypesResult.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\CRM\Type\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +class TypesResult extends AbstractResult +{ + /** + * @return TypeItemResult[] + * @throws BaseException + */ + public function getTypes(): array + { + $res = []; + foreach ($this->getCoreResponse()->getResponseData()->getResult()['types'] as $item) { + $res[] = new TypeItemResult($item); + } + + return $res; + } +} diff --git a/src/Services/CRM/Type/Service/Type.php b/src/Services/CRM/Type/Service/Type.php new file mode 100644 index 00000000..679c973b --- /dev/null +++ b/src/Services/CRM/Type/Service/Type.php @@ -0,0 +1,165 @@ + + * + * 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\CRM\Type\Service; + +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\AddedItemResult; +use Bitrix24\SDK\Core\Result\FieldsResult; +use Bitrix24\SDK\Core\Result\UpdatedItemResult; +use Bitrix24\SDK\Services\AbstractService; +use Bitrix24\SDK\Attributes\ApiEndpointMetadata; +use Bitrix24\SDK\Services\CRM\Type\Result\AddedTypeItemResult; +use Bitrix24\SDK\Services\CRM\Type\Result\DeletedItemResult; +use Bitrix24\SDK\Services\CRM\Type\Result\TypeItemResult; +use Bitrix24\SDK\Services\CRM\Type\Result\TypeResult; +use Bitrix24\SDK\Services\CRM\Type\Result\TypesResult; + +#[ApiServiceMetadata(new Scope(['crm']))] +class Type extends AbstractService +{ + /** + * This method retrieves information about the custom fields of the smart process settings. + * + * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-fields.html + * + * @return FieldsResult + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'crm.type.fields', + 'https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-fields.html', + 'This method retrieves information about the custom fields of the smart process settings.' + )] + public function fields(): FieldsResult + { + return new FieldsResult($this->core->call('crm.type.fields')); + } + + /** + * Create a new custom type crm.type.add + * + * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-add.html + * + * @param string $title + * @param int|null $entityTypeId + * @param array $parameters + * @return AddedTypeItemResult + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'crm.type.add', + 'https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-add.html', + 'This method creates a new SPA.' + )] + public function add(string $title, ?int $entityTypeId = null, array $parameters = []): AddedTypeItemResult + { + $fields = array_merge(['title' => $title], $parameters); + if ($entityTypeId !== null) { + // Значение entityTypeId обязано быть в одном из двух диапазонов: + // четным целым числом, которое больше или равно 1030 + // в диапазоне от 128 до 192 + $fields['entityTypeId'] = $entityTypeId; + } + + return new AddedTypeItemResult($this->core->call('crm.type.add', [ + 'fields' => $fields, + ])); + } + + /** + * The method retrieves information about the SPA with the identifier id. + * + * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get.html + * + * @return DeletedItemResult + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'crm.type.get', + 'https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get.html', + 'The method retrieves information about the SPA with the identifier id.' + )] + public function get(int $id): TypeResult + { + return new TypeResult($this->core->call('crm.type.get', ['id' => $id])); + } + + /** + * The method retrieves information about the SPA with the smart process type identifier entityTypeId. + * + * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get-by-entity-type-id.html + * + * @return DeletedItemResult + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'crm.type.getByEntityTypeId', + 'https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get-by-entity-type-id.html', + 'The method retrieves information about the SPA with the smart process type identifier entityTypeId.' + )] + public function getByEntityTypeId(int $entityTypeId): TypeResult + { + return new TypeResult($this->core->call('crm.type.getByEntityTypeId', ['entityTypeId' => $entityTypeId])); + } + + /** + * Get a list of custom types crm.type.list + * + * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get-by-entity-type-id.html + * + * @return DeletedItemResult + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'crm.type.list', + 'https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-list.html', + 'Get a list of custom types crm.type.list' + )] + public function list(array $order = [], array $filter = [], int $start = 0): TypesResult + { + return new TypesResult($this->core->call('crm.type.list', [ + 'order' => $order, + 'filter' => $filter, + 'start' => $start, + ])); + } + + /** + * This method deletes an existing smart process by the identifier id. + * + * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-delete.html + * + * @return DeletedItemResult + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'crm.type.delete', + 'https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-delete.html', + 'This method deletes an existing smart process by the identifier id.' + )] + public function delete(int $entityTypeId): DeletedItemResult + { + return new DeletedItemResult($this->core->call('crm.type.delete', ['entityTypeId' => $entityTypeId])); + } +} diff --git a/tests/CustomAssertions/CustomBitrix24Assertions.php b/tests/CustomAssertions/CustomBitrix24Assertions.php index dfb0e79c..dd1d32b7 100644 --- a/tests/CustomAssertions/CustomBitrix24Assertions.php +++ b/tests/CustomAssertions/CustomBitrix24Assertions.php @@ -48,15 +48,27 @@ protected function assertBitrix24AllResultItemFieldsAnnotated( } sort($propsFromAnnotations); - $this->assertEquals( - $fieldCodesFromApi, - $propsFromAnnotations, - sprintf( - 'in phpdocs annotations for class «%s» we not found fields from actual api response: %s', - $resultItemClassName, - implode(', ', array_values(array_diff($fieldCodesFromApi, $propsFromAnnotations))) - ) - ); + if (count($fieldCodesFromApi) >= $propsFromAnnotations) { + $this->assertEquals( + $fieldCodesFromApi, + $propsFromAnnotations, + sprintf( + 'in phpdocs annotations for class «%s» we not found fields from actual api response: «%s»', + $resultItemClassName, + implode(', ', array_values(array_diff($fieldCodesFromApi, $propsFromAnnotations))) + ) + ); + } else { + $this->assertEquals( + $fieldCodesFromApi, + $propsFromAnnotations, + sprintf( + 'in api response for class «%s» we not found some fields from class annotation: «%s»', + $resultItemClassName, + implode(', ', array_values(array_diff($propsFromAnnotations, $fieldCodesFromApi))) + ) + ); + } } protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation( @@ -253,8 +265,7 @@ protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation( ); break; case 'enum': - if (str_contains($fieldCode, 'DELETED_TYPE')) - { + if (str_contains($fieldCode, 'DELETED_TYPE')) { $this->assertTrue( str_contains($propsFromAnnotations[$fieldCode], 'int'), sprintf( @@ -266,7 +277,7 @@ protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation( 'int|null' ) ); - + break; } if (str_contains($fieldCode, 'durationType') @@ -284,7 +295,7 @@ protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation( 'string|null' ) ); - + break; } if (str_contains($fieldCode, 'priority') @@ -301,7 +312,7 @@ protected function assertBitrix24AllResultItemFieldsHasValidTypeAnnotation( 'int|null' ) ); - + break; } $this->assertTrue( From 5ab4f7be77efd77a585f70c1fccc72de1e721036 Mon Sep 17 00:00:00 2001 From: mesilov Date: Tue, 14 Oct 2025 03:15:06 +0600 Subject: [PATCH 02/33] Add integration tests for `Type` service and implement `crm.type.update` API method with corresponding result class. Signed-off-by: mesilov --- .../CRM/Type/Result/UpdatedTypeItemResult.php | 29 +++++ src/Services/CRM/Type/Service/Type.php | 39 +++++-- .../Services/CRM/Type/Service/TypeTest.php | 104 ++++++++++++++++++ 3 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 src/Services/CRM/Type/Result/UpdatedTypeItemResult.php create mode 100644 tests/Integration/Services/CRM/Type/Service/TypeTest.php diff --git a/src/Services/CRM/Type/Result/UpdatedTypeItemResult.php b/src/Services/CRM/Type/Result/UpdatedTypeItemResult.php new file mode 100644 index 00000000..939c802f --- /dev/null +++ b/src/Services/CRM/Type/Result/UpdatedTypeItemResult.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\CRM\Type\Result; + +use Bitrix24\SDK\Core\Result\AbstractResult; + +class UpdatedTypeItemResult extends AbstractResult +{ + public function type(): TypeItemResult + { + return new TypeItemResult($this->getCoreResponse()->getResponseData()->getResult()['type']); + } + + public function isSuccess(): bool + { + return true; + } +} \ No newline at end of file diff --git a/src/Services/CRM/Type/Service/Type.php b/src/Services/CRM/Type/Service/Type.php index 679c973b..66a27de3 100644 --- a/src/Services/CRM/Type/Service/Type.php +++ b/src/Services/CRM/Type/Service/Type.php @@ -14,13 +14,10 @@ namespace Bitrix24\SDK\Services\CRM\Type\Service; 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\AddedItemResult; use Bitrix24\SDK\Core\Result\FieldsResult; -use Bitrix24\SDK\Core\Result\UpdatedItemResult; use Bitrix24\SDK\Services\AbstractService; use Bitrix24\SDK\Attributes\ApiEndpointMetadata; use Bitrix24\SDK\Services\CRM\Type\Result\AddedTypeItemResult; @@ -28,6 +25,7 @@ use Bitrix24\SDK\Services\CRM\Type\Result\TypeItemResult; use Bitrix24\SDK\Services\CRM\Type\Result\TypeResult; use Bitrix24\SDK\Services\CRM\Type\Result\TypesResult; +use Bitrix24\SDK\Services\CRM\Type\Result\UpdatedTypeItemResult; #[ApiServiceMetadata(new Scope(['crm']))] class Type extends AbstractService @@ -72,9 +70,6 @@ public function add(string $title, ?int $entityTypeId = null, array $parameters { $fields = array_merge(['title' => $title], $parameters); if ($entityTypeId !== null) { - // Значение entityTypeId обязано быть в одном из двух диапазонов: - // четным целым числом, которое больше или равно 1030 - // в диапазоне от 128 до 192 $fields['entityTypeId'] = $entityTypeId; } @@ -83,12 +78,33 @@ public function add(string $title, ?int $entityTypeId = null, array $parameters ])); } + /** + * This method updates an existing SPA by its identifier id. + * + * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-update.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'crm.type.update', + 'https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-update.html', + 'This method updates an existing SPA by its identifier id.' + )] + public function update(int $id, array $fields): UpdatedTypeItemResult + { + return new UpdatedTypeItemResult($this->core->call('crm.type.update', [ + 'id' => $id, + 'fields' => $fields, + ])); + } + /** * The method retrieves information about the SPA with the identifier id. * * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get.html * - * @return DeletedItemResult + * @return TypeResult * @throws BaseException * @throws TransportException */ @@ -107,7 +123,8 @@ public function get(int $id): TypeResult * * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get-by-entity-type-id.html * - * @return DeletedItemResult + * @param int $entityTypeId + * @return TypeResult * @throws BaseException * @throws TransportException */ @@ -126,7 +143,10 @@ public function getByEntityTypeId(int $entityTypeId): TypeResult * * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-get-by-entity-type-id.html * - * @return DeletedItemResult + * @param array $order + * @param array $filter + * @param int $start + * @return TypesResult * @throws BaseException * @throws TransportException */ @@ -149,6 +169,7 @@ public function list(array $order = [], array $filter = [], int $start = 0): Typ * * @link https://apidocs.bitrix24.com/api-reference/crm/universal/user-defined-object-types/crm-type-delete.html * + * @param int $entityTypeId * @return DeletedItemResult * @throws BaseException * @throws TransportException diff --git a/tests/Integration/Services/CRM/Type/Service/TypeTest.php b/tests/Integration/Services/CRM/Type/Service/TypeTest.php new file mode 100644 index 00000000..4a444367 --- /dev/null +++ b/tests/Integration/Services/CRM/Type/Service/TypeTest.php @@ -0,0 +1,104 @@ + + * + * 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\CRM\Type\Service; + +use Bitrix24\SDK\Core\Fields\FieldsFilter; +use Bitrix24\SDK\Services\CRM\Type\Result\TypeItemResult; +use Bitrix24\SDK\Services\CRM\Type\Service\Type; +use Bitrix24\SDK\Tests\Integration\Fabric; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Bitrix24\SDK\Tests\CustomAssertions\CustomBitrix24Assertions; + +#[CoversClass(Type::class)] +class TypeTest extends TestCase +{ + use CustomBitrix24Assertions; + + protected Type $typeService; + + // in response, we have all system fields and some additional system fields + // public function testAllSystemFieldsAnnotated(): void + // { + // $propListFromApi = (new FieldsFilter())->filterSystemFields(array_keys($this->typeService->fields()->getFieldsDescription()['fields'])); + // $this->assertBitrix24AllResultItemFieldsAnnotated($propListFromApi, TypeItemResult::class); + // } + + public function testAdd(): void + { + $title = sprintf('%s test SPA type', time()); + $result = $this->typeService->add($title); + $this->assertEquals($title, $result->type()->title); + $this->assertTrue($this->typeService->delete($result->getId())->isSuccess()); + } + + public function testUpdate(): void + { + $title = sprintf('%s test SPA type', time()); + $result = $this->typeService->add($title); + $this->assertEquals($title, $result->type()->title); + + $title = sprintf('%s updated SPA type', time()); + $updatedResult = $this->typeService->update($result->getId(), ['title' => $title]); + $this->assertEquals($title, $updatedResult->type()->title); + + $this->assertTrue($this->typeService->delete($result->getId())->isSuccess()); + } + + public function testGet(): void + { + $title = sprintf('%s test SPA type', time()); + $addResult = $this->typeService->add($title); + + $result = $this->typeService->get($addResult->getId()); + $this->assertEquals($title, $addResult->type()->title); + $this->assertEquals($result->type()->id, $addResult->type()->id); + $this->assertTrue($this->typeService->delete($addResult->getId())->isSuccess()); + } + + public function testList(): void + { + $title = sprintf('%s test SPA type', time()); + $addResult = $this->typeService->add($title); + $result = $this->typeService->list([], ['id' => $addResult->getId()])->getTypes()[0]; + $this->assertEquals($title, $addResult->type()->title); + $this->assertEquals($result->id, $addResult->type()->id); + $this->assertTrue($this->typeService->delete($addResult->getId())->isSuccess()); + } + + public function testGetByEntityTypeId(): void + { + $title = sprintf('%s test SPA type', time()); + $result = $this->typeService->add($title); + $this->assertEquals($title, $result->type()->title); + + $resultTypeId = $this->typeService->getByEntityTypeId($result->type()->entityTypeId); + $this->assertEquals($title, $resultTypeId->type()->title); + $this->assertEquals($result->type()->id, $resultTypeId->type()->id); + $this->assertTrue($this->typeService->delete($result->getId())->isSuccess()); + } + + public function testDelete(): void + { + $title = sprintf('%s test SPA type', time()); + $result = $this->typeService->add($title); + $this->assertEquals($title, $result->type()->title); + $this->assertTrue($this->typeService->delete($result->getId())->isSuccess()); + } + + protected function setUp(): void + { + $this->typeService = Fabric::getServiceBuilder()->getCRMScope()->type(); + } +} From d9093e94d2e0420e94376ab0a79021f62cf20d17 Mon Sep 17 00:00:00 2001 From: mesilov Date: Tue, 14 Oct 2025 03:20:52 +0600 Subject: [PATCH 03/33] Update CHANGELOG for version 1.8.0, documenting new `Services\CRM\Type\Service\Type` methods for smart process management. Signed-off-by: mesilov --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c58edb0..01a748d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # b24-php-sdk change log +## 1.8.0 - 2025.11.01 (in progress) +### Added + +- Added service `Services\CRM\Type\Service\Type` with support methods, + see [crm.type.* methods](https://github.com/bitrix24/b24phpsdk/issues/274): + - `fields` method retrieves information about the custom fields of the smart process settings + - `add` method creates a new SPA + - `update` updates an existing SPA by its identifier id + - `get` method retrieves information about the SPA with the identifier id + - `getByEntityTypeId` method retrieves information about the SPA with the smart process type identifier entityTypeId + - `list` Get a list of custom types crm.type.list + - `delete` This method deletes an existing smart process by the identifier id + ## 1.7.0 - 2025.10.08 ### Added From 13c42672854f46106bf3bcbf744bbcdc88a41aa0 Mon Sep 17 00:00:00 2001 From: mesilov Date: Thu, 16 Oct 2025 02:28:46 +0600 Subject: [PATCH 04/33] Fix incorrect array offset in `ItemsResult::getItems` and update CHANGELOG with details. Signed-off-by: mesilov --- CHANGELOG.md | 12 ++++++++++++ src/Services/CRM/Item/Result/ItemsResult.php | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a748d0..97615e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ - `list` Get a list of custom types crm.type.list - `delete` This method deletes an existing smart process by the identifier id +### Fixed + +- Fixed wrong offset in `ItemsResult` [see details](https://github.com/bitrix24/b24phpsdk/issues/279) + +### Statistics + +``` +Bitrix24 API-methods count: +Supported in bitrix24-php-sdk methods count: +Coverage percentage: +``` + ## 1.7.0 - 2025.10.08 ### Added diff --git a/src/Services/CRM/Item/Result/ItemsResult.php b/src/Services/CRM/Item/Result/ItemsResult.php index 1650c476..3084fbcb 100644 --- a/src/Services/CRM/Item/Result/ItemsResult.php +++ b/src/Services/CRM/Item/Result/ItemsResult.php @@ -25,7 +25,7 @@ class ItemsResult extends AbstractResult public function getItems(): array { $items = []; - foreach ($this->getCoreResponse()->getResponseData()->getResult() as $item) { + foreach ($this->getCoreResponse()->getResponseData()->getResult()['items'] as $item) { $items[] = new ItemItemResult($item); } From 4ef75485e1a135a73a4a34461f0dc698683f9412 Mon Sep 17 00:00:00 2001 From: mesilov Date: Sun, 19 Oct 2025 02:02:08 +0600 Subject: [PATCH 05/33] Add integration tests for `Item` service and implement `getSmartProcessItem` method in `AbstractCrmItem`. Signed-off-by: mesilov --- .../CRM/Common/Result/AbstractCrmItem.php | 25 ++- .../Services/CRM/Item/Service/ItemTest.php | 188 ++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Services/CRM/Item/Service/ItemTest.php diff --git a/src/Services/CRM/Common/Result/AbstractCrmItem.php b/src/Services/CRM/Common/Result/AbstractCrmItem.php index bd6c8211..c09e93af 100644 --- a/src/Services/CRM/Common/Result/AbstractCrmItem.php +++ b/src/Services/CRM/Common/Result/AbstractCrmItem.php @@ -13,6 +13,7 @@ namespace Bitrix24\SDK\Services\CRM\Common\Result; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; use Bitrix24\SDK\Core\Result\AbstractItem; use Bitrix24\SDK\Services\CRM\Activity\ActivityContentType; use Bitrix24\SDK\Services\CRM\Activity\ActivityDirectionType; @@ -266,6 +267,28 @@ protected function getKeyWithUserfieldByFieldName(string $fieldName): mixed return $this->$fieldName; } + /** + * Get smart process item by entity type id + * + * @param positive-int $entityTypeId + * @throws InvalidArgumentException + */ + public function getSmartProcessItem(int $entityTypeId): ?int + { + if ($entityTypeId <= 0) { + throw new InvalidArgumentException('entityTypeId must be positive integer'); + } + $fieldKey = sprintf('PARENT_ID_%d', $entityTypeId); + if (!array_key_exists($fieldKey, $this->data)) { + throw new InvalidArgumentException(sprintf('field «%s» for smart process with entityTypeId «%d» not found', $fieldKey, $entityTypeId)); + } + if ($this->data[$fieldKey] === '' || $this->data[$fieldKey] === null) { + return null; + } + + return (int)$this->data[$fieldKey]; + } + public function __construct(array $data, Currency $currency = null) { parent::__construct($data); @@ -273,4 +296,4 @@ public function __construct(array $data, Currency $currency = null) $this->currency = $currency; } } -} \ No newline at end of file +} diff --git a/tests/Integration/Services/CRM/Item/Service/ItemTest.php b/tests/Integration/Services/CRM/Item/Service/ItemTest.php new file mode 100644 index 00000000..34320d6f --- /dev/null +++ b/tests/Integration/Services/CRM/Item/Service/ItemTest.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\CRM\Item\Service; + +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use Bitrix24\SDK\Core\Exceptions\ItemNotFoundException; +use Bitrix24\SDK\Services\CRM\Contact\Service\Contact; +use Bitrix24\SDK\Services\CRM\Item\Service\Item; +use Bitrix24\SDK\Services\CRM\Type\Service\Type; +use Bitrix24\SDK\Tests\Integration\Fabric; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Bitrix24\SDK\Tests\CustomAssertions\CustomBitrix24Assertions; + +#[CoversClass(Item::class)] +class ItemTest extends TestCase +{ + use CustomBitrix24Assertions; + + protected Type $typeService; + + protected Item $itemService; + + protected Contact $contactService; + + public function testAdd(): void + { + $title = sprintf('%s test SPA type', time()); + $addedTypeItemResult = $this->typeService->add($title); + $this->assertEquals($title, $addedTypeItemResult->type()->title); + + // add item to SP + $this->itemService->add($addedTypeItemResult->type()->entityTypeId, [ + 'title' => sprintf('test sp item %s', time()), + 'xmlId' => sprintf('b24-php-sdk-test-item-%s', time()) + ])->item(); + + $this->assertTrue($this->typeService->delete($addedTypeItemResult->getId())->isSuccess()); + } + + public function testUpdate(): void + { + $title = sprintf('%s test SPA type', time()); + $addedTypeItemResult = $this->typeService->add($title); + $this->assertEquals($title, $addedTypeItemResult->type()->title); + + // add item to SP + $itemItemResult = $this->itemService->add($addedTypeItemResult->type()->entityTypeId, [ + 'title' => sprintf('test sp item %s', time()), + 'xmlId' => sprintf('b24-php-sdk-test-item-%s', time()) + ])->item(); + + // update item + $newTitle = sprintf('test sp item %s updated', time()); + $updatedItemResult = $this->itemService->update($addedTypeItemResult->type()->entityTypeId, $itemItemResult->id, ['title' => $newTitle]); + $this->assertEquals($newTitle, $updatedItemResult->item()->title); + $this->assertTrue($updatedItemResult->isSuccess()); + + $updatedItem = $this->itemService->get($addedTypeItemResult->type()->entityTypeId, $itemItemResult->id)->item(); + $this->assertEquals($newTitle, $updatedItem->title); + + $this->assertTrue($this->typeService->delete($addedTypeItemResult->getId())->isSuccess()); + } + + public function testGet(): void + { + $title = sprintf('%s test SPA type', time()); + $addedTypeItemResult = $this->typeService->add($title); + $this->assertEquals($title, $addedTypeItemResult->type()->title); + + // add item to SP + $itemItemResult = $this->itemService->add($addedTypeItemResult->type()->entityTypeId, [ + 'title' => sprintf('test sp item %s', time()), + 'xmlId' => sprintf('b24-php-sdk-test-item-%s', time()) + ])->item(); + + $item = $this->itemService->get($addedTypeItemResult->type()->entityTypeId, $itemItemResult->id)->item(); + $this->assertEquals($itemItemResult->title, $item->title); + $this->assertEquals($itemItemResult->xmlId, $item->xmlId); + + $this->assertTrue($this->typeService->delete($addedTypeItemResult->type()->entityTypeId)->isSuccess()); + } + + public function testList(): void + { + $title = sprintf('%s test SPA type', time()); + $addedTypeItemResult = $this->typeService->add($title); + $this->assertEquals($title, $addedTypeItemResult->type()->title); + + // add item to SP + $this->itemService->add($addedTypeItemResult->type()->entityTypeId, [ + 'title' => sprintf('test sp item %s', time()), + 'xmlId' => sprintf('b24-php-sdk-test-item-%s', time()) + ])->item(); + $this->itemService->add($addedTypeItemResult->type()->entityTypeId, [ + 'title' => sprintf('test sp item %s', time()), + 'xmlId' => sprintf('b24-php-sdk-test-item-%s', time()) + ])->item(); + + $items = $this->itemService->list($addedTypeItemResult->type()->entityTypeId, [], [], [])->getItems(); + $this->assertCount(2, $items); + + $this->assertTrue($this->typeService->delete($addedTypeItemResult->type()->entityTypeId)->isSuccess()); + } + + public function testGetSmartProcessItem(): void + { + $title = sprintf('%s test SPA type', time()); + $addedTypeItemResult = $this->typeService->add($title, null, [ + 'relations' => [ + 'child' => [ + [ + // allow bind to contact + 'entityTypeId' => 3, + 'isChildrenListEnabled' => 'N', + 'isPredefined' => 'N' + ] + ] + ] + ]); + $this->assertEquals($title, $addedTypeItemResult->type()->title); + + // add item to SP + $itemItemResult = $this->itemService->add($addedTypeItemResult->type()->entityTypeId, [ + 'title' => sprintf('test sp item %s', time()), + 'xmlId' => sprintf('b24-php-sdk-test-item-%s', time()) + ])->item(); + + // add contact with sp item + // @phpstan-ignore-next-line argument.type + $b24ContactId = $this->contactService->add([ + 'NAME' => sprintf('Test contact %s', time()), + 'PARENT_ID_' . $addedTypeItemResult->type()->entityTypeId => $itemItemResult->id, + ])->getId(); + $contact = $this->contactService->get($b24ContactId)->contact(); + $this->assertEquals( + $itemItemResult->id, + $contact->getSmartProcessItem($addedTypeItemResult->type()->entityTypeId) + ); + $this->expectException(InvalidArgumentException::class); + $contact->getSmartProcessItem(1); + + $b24ContactId = $this->contactService->add([ + 'NAME' => sprintf('Test contact %s', time()) + ])->getId(); + $this->assertNull($this->contactService->get($b24ContactId)->contact()->getSmartProcessItem($addedTypeItemResult->type()->entityTypeId)); + + $this->assertTrue($this->typeService->delete($addedTypeItemResult->type()->entityTypeId)->isSuccess()); + } + + public function testDelete(): void + { + $title = sprintf('%s test SPA type', time()); + $addedTypeItemResult = $this->typeService->add($title); + $this->assertEquals($title, $addedTypeItemResult->type()->title); + + // add item to SP + $itemItemResult = $this->itemService->add($addedTypeItemResult->type()->entityTypeId, [ + 'title' => sprintf('test sp item %s', time()), + 'xmlId' => sprintf('b24-php-sdk-test-item-%s', time()) + ])->item(); + + $this->assertTrue($this->itemService->delete($addedTypeItemResult->type()->entityTypeId, $itemItemResult->id)->isSuccess()); + + $this->expectException(ItemNotFoundException::class); + $this->itemService->get($addedTypeItemResult->type()->entityTypeId, $itemItemResult->id)->item(); + + $this->assertTrue($this->typeService->delete($addedTypeItemResult->type()->entityTypeId)->isSuccess()); + } + + protected function setUp(): void + { + $this->typeService = Fabric::getServiceBuilder()->getCRMScope()->type(); + $this->itemService = Fabric::getServiceBuilder()->getCRMScope()->item(); + $this->contactService = Fabric::getServiceBuilder()->getCRMScope()->contact(); + } +} From ea775489d8170512028fafc18c333e266ec633e4 Mon Sep 17 00:00:00 2001 From: mesilov Date: Sun, 19 Oct 2025 02:04:46 +0600 Subject: [PATCH 06/33] Add `DeletedItemResult` and `UpdatedItemResult` classes, improve exception handling for `not_found`, and update default `select` behavior in `Item::list` method. Update CHANGELOG with related fixes. Signed-off-by: mesilov --- CHANGELOG.md | 4 +++ src/Core/ApiLevelErrorHandler.php | 1 + .../CRM/Item/Result/DeletedItemResult.php | 24 +++++++++++++++ .../CRM/Item/Result/UpdatedItemResult.php | 29 +++++++++++++++++++ src/Services/CRM/Item/Service/Item.php | 8 +++-- 5 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/Services/CRM/Item/Result/DeletedItemResult.php create mode 100644 src/Services/CRM/Item/Result/UpdatedItemResult.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 97615e69..f1d23f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,14 @@ - `getByEntityTypeId` method retrieves information about the SPA with the smart process type identifier entityTypeId - `list` Get a list of custom types crm.type.list - `delete` This method deletes an existing smart process by the identifier id +- For `AbstractCrmItem` added method `getSmartProcessItem` to get smart process item, [see details](https://github.com/bitrix24/b24phpsdk/issues/282) + + ### Fixed - Fixed wrong offset in `ItemsResult` [see details](https://github.com/bitrix24/b24phpsdk/issues/279) +- Fixed wrong exception for method `crm.item.get`, now it `ItemNotFoundException` [see details](https://github.com/bitrix24/b24phpsdk/issues/282) ### Statistics diff --git a/src/Core/ApiLevelErrorHandler.php b/src/Core/ApiLevelErrorHandler.php index 018d84a7..a3a8af68 100644 --- a/src/Core/ApiLevelErrorHandler.php +++ b/src/Core/ApiLevelErrorHandler.php @@ -156,6 +156,7 @@ private function handleError(array $responseBody, ?string $batchCommandId = null throw new PaymentRequiredException(sprintf('%s - %s', $errorCode, $errorDescription)); case 'wrong_client': throw new WrongClientException(sprintf('%s - %s', $errorCode, $errorDescription)); + case 'not_found': case 'error_not_found': throw new ItemNotFoundException(sprintf('%s - %s', $errorCode, $errorDescription)); default: diff --git a/src/Services/CRM/Item/Result/DeletedItemResult.php b/src/Services/CRM/Item/Result/DeletedItemResult.php new file mode 100644 index 00000000..43e56f28 --- /dev/null +++ b/src/Services/CRM/Item/Result/DeletedItemResult.php @@ -0,0 +1,24 @@ + + * + * 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\CRM\Item\Result; + +use Bitrix24\SDK\Core\Result\AbstractResult; + +class DeletedItemResult extends AbstractResult +{ + public function isSuccess(): bool + { + return true; + } +} diff --git a/src/Services/CRM/Item/Result/UpdatedItemResult.php b/src/Services/CRM/Item/Result/UpdatedItemResult.php new file mode 100644 index 00000000..2cf2b191 --- /dev/null +++ b/src/Services/CRM/Item/Result/UpdatedItemResult.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\CRM\Item\Result; + +use Bitrix24\SDK\Core\Result\AbstractResult; + +class UpdatedItemResult extends AbstractResult +{ + public function item(): ItemItemResult + { + return new ItemItemResult($this->getCoreResponse()->getResponseData()->getResult()['item']); + } + + public function isSuccess(): bool + { + return true; + } +} diff --git a/src/Services/CRM/Item/Service/Item.php b/src/Services/CRM/Item/Service/Item.php index 3efedb78..2e7ad76d 100644 --- a/src/Services/CRM/Item/Service/Item.php +++ b/src/Services/CRM/Item/Service/Item.php @@ -19,9 +19,9 @@ 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\Core\Result\FieldsResult; -use Bitrix24\SDK\Core\Result\UpdatedItemResult; +use Bitrix24\SDK\Services\CRM\Item\Result\UpdatedItemResult; +use Bitrix24\SDK\Services\CRM\Item\Result\DeletedItemResult; use Bitrix24\SDK\Services\AbstractService; use Bitrix24\SDK\Services\CRM\Item\Result\ItemResult; use Bitrix24\SDK\Services\CRM\Item\Result\ItemsResult; @@ -137,6 +137,10 @@ public function get(int $entityTypeId, int $id): ItemResult )] public function list(int $entityTypeId, array $order, array $filter, array $select, int $startItem = 0): ItemsResult { + if ($select === []) { + $select = ['*']; + } + return new ItemsResult( $this->core->call( 'crm.item.list', From 7c72174126c561d1ba9a3379e576918f101a8f79 Mon Sep 17 00:00:00 2001 From: mesilov Date: Wed, 29 Oct 2025 13:23:14 +0600 Subject: [PATCH 07/33] Add `project` type to `PortalLicenseFamily` enum and update CHANGELOG with details. Signed-off-by: mesilov --- CHANGELOG.md | 1 + src/Application/PortalLicenseFamily.php | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d23f2d..3d73054a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Fixed wrong offset in `ItemsResult` [see details](https://github.com/bitrix24/b24phpsdk/issues/279) - Fixed wrong exception for method `crm.item.get`, now it `ItemNotFoundException` [see details](https://github.com/bitrix24/b24phpsdk/issues/282) +- Fixed added type `project` in enum `PortalLicenseFamily` [see details](https://github.com/bitrix24/b24phpsdk/issues/286) ### Statistics diff --git a/src/Application/PortalLicenseFamily.php b/src/Application/PortalLicenseFamily.php index 718fa953..42a63b0b 100644 --- a/src/Application/PortalLicenseFamily.php +++ b/src/Application/PortalLicenseFamily.php @@ -23,6 +23,7 @@ enum PortalLicenseFamily: string case basic = 'basic'; case std = 'std'; case pro = 'pro'; + case project = 'project'; case en = 'en'; case nfr = 'nfr'; } \ No newline at end of file From 59637163641d23c328080898952cb4047ae474d6 Mon Sep 17 00:00:00 2001 From: mesilov Date: Sun, 2 Nov 2025 18:32:52 +0600 Subject: [PATCH 08/33] Add support for CRM Contact events: `onCrmContactAdd`, `onCrmContactUpdate`, and `onCrmContactDelete`. Update factory and CHANGELOG with details. Signed-off-by: mesilov --- CHANGELOG.md | 6 ++- .../Events/CrmContactEventsFactory.php | 54 +++++++++++++++++++ .../OnCrmContactAdd/OnCrmContactAdd.php | 26 +++++++++ .../OnCrmContactAddPayload.php | 23 ++++++++ .../OnCrmContactDelete/OnCrmContactDelete.php | 26 +++++++++ .../OnCrmContactDeletePayload.php | 23 ++++++++ .../OnCrmContactUpdate/OnCrmContactUpdate.php | 26 +++++++++ .../OnCrmContactUpdatePayload.php | 23 ++++++++ src/Services/RemoteEventsFactory.php | 2 + 9 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/Services/CRM/Contact/Events/CrmContactEventsFactory.php create mode 100644 src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAdd.php create mode 100644 src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAddPayload.php create mode 100644 src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDelete.php create mode 100644 src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDeletePayload.php create mode 100644 src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdate.php create mode 100644 src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdatePayload.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d73054a..0a0418e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ - `list` Get a list of custom types crm.type.list - `delete` This method deletes an existing smart process by the identifier id - For `AbstractCrmItem` added method `getSmartProcessItem` to get smart process item, [see details](https://github.com/bitrix24/b24phpsdk/issues/282) - - +- Added support for events, [see details](https://github.com/bitrix24/b24phpsdk/issues/288) + - `onCrmContactAdd` + - `onCrmContactUpdate` + - `onCrmContactDelete` ### Fixed diff --git a/src/Services/CRM/Contact/Events/CrmContactEventsFactory.php b/src/Services/CRM/Contact/Events/CrmContactEventsFactory.php new file mode 100644 index 00000000..7b8deafa --- /dev/null +++ b/src/Services/CRM/Contact/Events/CrmContactEventsFactory.php @@ -0,0 +1,54 @@ + + * + * 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\CRM\Contact\Events; + +use Bitrix24\SDK\Core\Contracts\Events\EventInterface; +use Bitrix24\SDK\Core\Contracts\Events\EventsFabricInterface; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use Bitrix24\SDK\Services\CRM\Contact\Events\OnCrmContactAdd\OnCrmContactAdd; +use Bitrix24\SDK\Services\CRM\Contact\Events\OnCrmContactDelete\OnCrmContactDelete; +use Bitrix24\SDK\Services\CRM\Contact\Events\OnCrmContactUpdate\OnCrmContactUpdate; +use Symfony\Component\HttpFoundation\Request; + +readonly class CrmContactEventsFactory implements EventsFabricInterface +{ + public function isSupport(string $eventCode): bool + { + return in_array(strtoupper($eventCode), [ + OnCrmContactAdd::CODE, + OnCrmContactUpdate::CODE, + OnCrmContactDelete::CODE, + ], true); + } + + /** + * @throws InvalidArgumentException + */ + public function create(Request $eventRequest): EventInterface + { + $eventPayload = $eventRequest->request->all(); + if (!array_key_exists('event', $eventPayload)) { + throw new InvalidArgumentException('«event» key not found in event payload'); + } + + return match ($eventPayload['event']) { + OnCrmContactAdd::CODE => new OnCrmContactAdd($eventRequest), + OnCrmContactUpdate::CODE => new OnCrmContactUpdate($eventRequest), + OnCrmContactDelete::CODE => new OnCrmContactDelete($eventRequest), + default => throw new InvalidArgumentException( + sprintf('Unexpected event code «%s»', $eventPayload['event']) + ), + }; + } +} diff --git a/src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAdd.php b/src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAdd.php new file mode 100644 index 00000000..32bb7462 --- /dev/null +++ b/src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAdd.php @@ -0,0 +1,26 @@ + + * + * 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\CRM\Contact\Events\OnCrmContactAdd; + +use Bitrix24\SDK\Application\Requests\Events\AbstractEventRequest; + +class OnCrmContactAdd extends AbstractEventRequest +{ + public const CODE = 'ONCRMCONTACTADD'; + + public function getPayload(): OnCrmContactAddPayload + { + return new OnCrmContactAddPayload($this->eventPayload['data']); + } +} diff --git a/src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAddPayload.php b/src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAddPayload.php new file mode 100644 index 00000000..7234c393 --- /dev/null +++ b/src/Services/CRM/Contact/Events/OnCrmContactAdd/OnCrmContactAddPayload.php @@ -0,0 +1,23 @@ + + * + * 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\CRM\Contact\Events\OnCrmContactAdd; + +use Bitrix24\SDK\Core\Result\AbstractItem; + +/** + * @property-read positive-int $ID + */ +class OnCrmContactAddPayload extends AbstractItem +{ +} diff --git a/src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDelete.php b/src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDelete.php new file mode 100644 index 00000000..e46fdea0 --- /dev/null +++ b/src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDelete.php @@ -0,0 +1,26 @@ + + * + * 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\CRM\Contact\Events\OnCrmContactDelete; + +use Bitrix24\SDK\Application\Requests\Events\AbstractEventRequest; + +class OnCrmContactDelete extends AbstractEventRequest +{ + public const CODE = 'ONCRMCONTACTDELETE'; + + public function getPayload(): OnCrmContactDeletePayload + { + return new OnCrmContactDeletePayload($this->eventPayload['data']); + } +} diff --git a/src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDeletePayload.php b/src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDeletePayload.php new file mode 100644 index 00000000..ab01e687 --- /dev/null +++ b/src/Services/CRM/Contact/Events/OnCrmContactDelete/OnCrmContactDeletePayload.php @@ -0,0 +1,23 @@ + + * + * 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\CRM\Contact\Events\OnCrmContactDelete; + +use Bitrix24\SDK\Core\Result\AbstractItem; + +/** + * @property-read positive-int $ID + */ +class OnCrmContactDeletePayload extends AbstractItem +{ +} diff --git a/src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdate.php b/src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdate.php new file mode 100644 index 00000000..ac29e29b --- /dev/null +++ b/src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdate.php @@ -0,0 +1,26 @@ + + * + * 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\CRM\Contact\Events\OnCrmContactUpdate; + +use Bitrix24\SDK\Application\Requests\Events\AbstractEventRequest; + +class OnCrmContactUpdate extends AbstractEventRequest +{ + public const CODE = 'ONCRMCONTACTUPDATE'; + + public function getPayload(): OnCrmContactUpdatePayload + { + return new OnCrmContactUpdatePayload($this->eventPayload['data']); + } +} diff --git a/src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdatePayload.php b/src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdatePayload.php new file mode 100644 index 00000000..a7dd663d --- /dev/null +++ b/src/Services/CRM/Contact/Events/OnCrmContactUpdate/OnCrmContactUpdatePayload.php @@ -0,0 +1,23 @@ + + * + * 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\CRM\Contact\Events\OnCrmContactUpdate; + +use Bitrix24\SDK\Core\Result\AbstractItem; + +/** + * @property-read positive-int $ID + */ +class OnCrmContactUpdatePayload extends AbstractItem +{ +} diff --git a/src/Services/RemoteEventsFactory.php b/src/Services/RemoteEventsFactory.php index f0a3d334..5a9a498e 100644 --- a/src/Services/RemoteEventsFactory.php +++ b/src/Services/RemoteEventsFactory.php @@ -23,6 +23,7 @@ use Bitrix24\SDK\Core\Requests\Events\UnsupportedRemoteEvent; use Bitrix24\SDK\Services\Calendar\Events\CalendarEventsFactory; use Bitrix24\SDK\Services\CRM\Company\Events\CrmCompanyEventsFactory; +use Bitrix24\SDK\Services\CRM\Contact\Events\CrmContactEventsFactory; use Bitrix24\SDK\Services\Sale; use Bitrix24\SDK\Services\Telephony\Events\TelephonyEventsFabric; use Bitrix24\SDK\Services\Telephony\Events\TelephonyEventsFactory; @@ -150,6 +151,7 @@ public static function init(LoggerInterface $logger): self new TelephonyEventsFactory(), new CalendarEventsFactory(), new CrmCompanyEventsFactory(), + new CrmContactEventsFactory(), new Sale\Events\SaleEventsFactory(), ], $logger From b0c7bcf26da07ffe7ae7b89e183ac643c7758d25 Mon Sep 17 00:00:00 2001 From: mesilov Date: Mon, 3 Nov 2025 15:27:06 +0600 Subject: [PATCH 09/33] Refactor `RemoteEventsFactory`: add `create` and `validate` methods for handling incoming events, deprecate `createEvent`. Update imports and CHANGELOG. Signed-off-by: mesilov --- CHANGELOG.md | 32 ++++++++----- src/Services/RemoteEventsFactory.php | 72 ++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0418e5..757a0b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,25 @@ # b24-php-sdk change log ## 1.8.0 - 2025.11.01 (in progress) + ### Added - Added service `Services\CRM\Type\Service\Type` with support methods, see [crm.type.* methods](https://github.com/bitrix24/b24phpsdk/issues/274): - - `fields` method retrieves information about the custom fields of the smart process settings - - `add` method creates a new SPA - - `update` updates an existing SPA by its identifier id - - `get` method retrieves information about the SPA with the identifier id - - `getByEntityTypeId` method retrieves information about the SPA with the smart process type identifier entityTypeId - - `list` Get a list of custom types crm.type.list - - `delete` This method deletes an existing smart process by the identifier id + - `fields` method retrieves information about the custom fields of the smart process settings + - `add` method creates a new SPA + - `update` updates an existing SPA by its identifier id + - `get` method retrieves information about the SPA with the identifier id + - `getByEntityTypeId` method retrieves information about the SPA with the smart process type identifier entityTypeId + - `list` Get a list of custom types crm.type.list + - `delete` This method deletes an existing smart process by the identifier id - For `AbstractCrmItem` added method `getSmartProcessItem` to get smart process item, [see details](https://github.com/bitrix24/b24phpsdk/issues/282) -- Added support for events, [see details](https://github.com/bitrix24/b24phpsdk/issues/288) - - `onCrmContactAdd` - - `onCrmContactUpdate` - - `onCrmContactDelete` +- Added support for events, [see details](https://github.com/bitrix24/b24phpsdk/issues/288) + - `onCrmContactAdd` + - `onCrmContactUpdate` + - `onCrmContactDelete` +- Added separated methods `RemoteEventsFactory::create` and `RemoteEventsFactory::validate` for create and validate incoming + events, [see details](https://github.com/bitrix24/b24phpsdk/issues/291) ### Fixed @@ -24,6 +27,10 @@ - Fixed wrong exception for method `crm.item.get`, now it `ItemNotFoundException` [see details](https://github.com/bitrix24/b24phpsdk/issues/282) - Fixed added type `project` in enum `PortalLicenseFamily` [see details](https://github.com/bitrix24/b24phpsdk/issues/286) +### Deprecated + +- Method `RemoteEventsFactory::createEvent` marked as deprecated, use `RemoteEventsFactory::create` and `RemoteEventsFactory::validate` instead + ### Statistics ``` @@ -239,6 +246,7 @@ Coverage percentage: - `list` retrieves a list of property bindings - `deleteByFilter` removes the property relation - `getFields` returns the available fields for property binding + ### Fixed - Fixed Incorrect data loading in `Core\Batch::getTraversableList()` with desc sorting by ID [see details](https://github.com/bitrix24/b24phpsdk/issues/246) @@ -251,7 +259,6 @@ Supported in bitrix24-php-sdk methods count: 632 Coverage percentage: 54.39% 🚀 ``` - ## 1.6.0 – 2025.09.01 ### Added @@ -432,7 +439,6 @@ Supported in bitrix24-php-sdk methods count: 476 Coverage percentage: 41.03% 🚀 ``` - ## 1.5.0 – 2025.08.01 ### Added diff --git a/src/Services/RemoteEventsFactory.php b/src/Services/RemoteEventsFactory.php index 5a9a498e..e273cd28 100644 --- a/src/Services/RemoteEventsFactory.php +++ b/src/Services/RemoteEventsFactory.php @@ -13,7 +13,6 @@ namespace Bitrix24\SDK\Services; -use Bitrix24\SDK\Application\Requests\Events\ApplicationLifeCycleEventsFabric; use Bitrix24\SDK\Application\Requests\Events\ApplicationLifeCycleEventsFactory; use Bitrix24\SDK\Application\Requests\Events\OnApplicationInstall\OnApplicationInstall; use Bitrix24\SDK\Core\Contracts\Events\EventInterface; @@ -24,8 +23,6 @@ use Bitrix24\SDK\Services\Calendar\Events\CalendarEventsFactory; use Bitrix24\SDK\Services\CRM\Company\Events\CrmCompanyEventsFactory; use Bitrix24\SDK\Services\CRM\Contact\Events\CrmContactEventsFactory; -use Bitrix24\SDK\Services\Sale; -use Bitrix24\SDK\Services\Telephony\Events\TelephonyEventsFabric; use Bitrix24\SDK\Services\Telephony\Events\TelephonyEventsFactory; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -65,6 +62,74 @@ public static function isCanProcess(Request $request): bool return array_key_exists('event', $payload); } + /** + * Create an event object from a remote event request from Bitrix24 + * If event supported in SDK it will create a concrete object, in other cases it will be created UnsupportedRemoteEvent object + * + * @throws InvalidArgumentException + */ + public function create(Request $request): EventInterface + { + $payload = []; + parse_str($request->getContent(), $payload); + + if (!self::isCanProcess($request)) { + throw new InvalidArgumentException('event request is not valid'); + } + + $event = new UnsupportedRemoteEvent($request); + foreach ($this->eventsFabrics as $itemFabric) { + if ($itemFabric->isSupport($payload['event'])) { + $event = $itemFabric->create($request); + break; + } + } + + $this->logger->debug('RemoteEventsFactory.create.eventCreated', [ + 'eventClassName' => $event::class, + 'eventCode' => $event->getEventCode() + ]); + return $event; + } + + /** + * @param EventInterface $event + * @param non-empty-string $applicationToken + * @throws WrongSecuritySignatureException + * @throws InvalidArgumentException + */ + public function validate(EventInterface $event, string $applicationToken): void + { + if ($applicationToken === '') { + throw new InvalidArgumentException('application token cannot be empty string'); + } + + if ($event instanceof OnApplicationInstall) { + // skip OnApplicationInstall event check because application_token is null + // first event in application lifecycle is OnApplicationInstall and this event contains application_token + return; + } + + // check event security signature + // see https://apidocs.bitrix24.com/api-reference/events/safe-event-handlers.html + // all next events MUST validate for application_token signature + if ($applicationToken !== $event->getAuth()->application_token) { + $this->logger->warning('RemoteEventsFactory.validate.eventNotValidSignature', [ + 'eventCode' => $event->getEventCode(), + 'storedApplicationToken' => $applicationToken, + 'eventApplicationToken' => $event->getAuth()->application_token, + 'eventPayload' => $event->getEventPayload(), + ]); + + throw new WrongSecuritySignatureException( + sprintf( + 'Wrong security signature for event %s', + $event->getEventCode() + ) + ); + } + } + /** * Create event object from remote event request from Bitrix24 * If event supported in SDK it will create concrete object, in other cases it will be created UnsupportedRemoteEvent object @@ -74,6 +139,7 @@ public static function isCanProcess(Request $request): bool * @return EventInterface * @throws InvalidArgumentException * @throws WrongSecuritySignatureException + * @deprecated use RemoteEventsFactory::create and RemoteEventsFactory::validate instead */ public function createEvent(Request $request, ?string $applicationToken): EventInterface { From 3ba79387334fbb360e52b0e238880cc6aa2e708b Mon Sep 17 00:00:00 2001 From: mesilov Date: Mon, 3 Nov 2025 15:59:44 +0600 Subject: [PATCH 10/33] Add unit tests for `RemoteEventsFactory`: include coverage for `create` and `validate` methods, handling various event types, validation logic, and exceptions. Signed-off-by: mesilov --- .../Unit/Services/RemoteEventsFactoryTest.php | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 tests/Unit/Services/RemoteEventsFactoryTest.php diff --git a/tests/Unit/Services/RemoteEventsFactoryTest.php b/tests/Unit/Services/RemoteEventsFactoryTest.php new file mode 100644 index 00000000..11082713 --- /dev/null +++ b/tests/Unit/Services/RemoteEventsFactoryTest.php @@ -0,0 +1,459 @@ + + * + * 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; + +use Bitrix24\SDK\Application\Requests\Events\OnApplicationInstall\OnApplicationInstall; +use Bitrix24\SDK\Core\Contracts\Events\EventInterface; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use Bitrix24\SDK\Core\Exceptions\WrongSecuritySignatureException; +use Bitrix24\SDK\Core\Requests\Events\UnsupportedRemoteEvent; +use Bitrix24\SDK\Services\CRM\Contact\Events\OnCrmContactAdd\OnCrmContactAdd; +use Bitrix24\SDK\Services\RemoteEventsFactory; +use Generator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use Symfony\Component\HttpFoundation\Request; + +#[CoversClass(RemoteEventsFactory::class)] +class RemoteEventsFactoryTest extends TestCase +{ + private RemoteEventsFactory $factory; + + protected function setUp(): void + { + $this->factory = RemoteEventsFactory::init(new NullLogger()); + } + + // ==================== Tests for create() method ==================== + + #[Test] + #[TestDox('create() should successfully create a CRM Contact Add event from valid request')] + public function testCreateCrmContactAddEvent(): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => 'ONCRMCONTACTADD', + 'event_handler_id' => '196', + 'data' => ['FIELDS' => ['ID' => '264442']], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => '076a0769007d83b20058f18a0000000100000694f171445612e564746d04afebba973e', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm,placement,user_brief,pull,userfieldconfig', + 'domain' => 'bitrix24-php-sdk-playground.bitrix24.ru', + 'server_endpoint' => 'https://oauth.bitrix24.tech/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://bitrix24-php-sdk-playground.bitrix24.com/rest/', + 'member_id' => '010b6886ebc205e43ae65000ee00addb', + 'user_id' => '1', + 'application_token' => 'e24831714bb347622e3ef25af61525cf', + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + $this->assertInstanceOf(OnCrmContactAdd::class, $event); + $this->assertEquals('ONCRMCONTACTADD', $event->getEventCode()); + $this->assertEquals('264442', $event->getEventPayload()['data']['FIELDS']['ID']); + $this->assertEquals('e24831714bb347622e3ef25af61525cf', $event->getAuth()->application_token); + } + + #[Test] + #[TestDox('create() should successfully create an OnApplicationInstall event from valid request')] + public function testCreateApplicationInstallEvent(): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => 'ONAPPINSTALL', + 'event_handler_id' => '1', + 'data' => [ + 'VERSION' => '1', + 'LANGUAGE_ID' => 'en', + ], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm,placement,user_brief', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => 'test_app_token', + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + $this->assertInstanceOf(OnApplicationInstall::class, $event); + $this->assertEquals('ONAPPINSTALL', $event->getEventCode()); + } + + #[Test] + #[TestDox('create() should return UnsupportedRemoteEvent for unknown event codes')] + public function testCreateUnsupportedEvent(): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => 'UNSUPPORTEDEVENT', + 'event_handler_id' => '1', + 'data' => [], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => 'test_app_token', + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + $this->assertInstanceOf(UnsupportedRemoteEvent::class, $event); + $this->assertEquals('UNSUPPORTEDEVENT', $event->getEventCode()); + } + + #[Test] + #[TestDox('create() should throw InvalidArgumentException when event key is missing')] + public function testCreateThrowsExceptionWhenEventKeyMissing(): void + { + $rawRequest = $this->buildRawRequest([ + 'event_handler_id' => '1', + 'data' => [], + 'ts' => '1762089975', + ]); + + $request = $this->createRequest($rawRequest); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('event request is not valid'); + $this->factory->create($request); + } + + #[Test] + #[TestDox('create() should handle event code case variations')] + #[DataProvider('eventCodeCaseProvider')] + public function testCreateHandlesEventCodeCase(string $eventCode, string $expectedClass): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => $eventCode, + 'event_handler_id' => '1', + 'data' => ['FIELDS' => ['ID' => '1']], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => 'test_app_token', + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + $this->assertInstanceOf($expectedClass, $event); + } + + // ==================== Tests for validate() method ==================== + + #[Test] + #[TestDox('validate() should pass when application tokens match')] + public function testValidatePassesWithMatchingToken(): void + { + $applicationToken = 'e24831714bb347622e3ef25af61525cf'; + $rawRequest = $this->buildRawRequest([ + 'event' => 'ONCRMCONTACTADD', + 'event_handler_id' => '196', + 'data' => ['FIELDS' => ['ID' => '264442']], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => '076a0769007d83b20058f18a0000000100000694f171445612e564746d04afebba973e', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm,placement,user_brief,pull,userfieldconfig', + 'domain' => 'bitrix24-php-sdk-playground.bitrix24.ru', + 'server_endpoint' => 'https://oauth.bitrix24.tech/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://bitrix24-php-sdk-playground.bitrix24.com/rest/', + 'member_id' => '010b6886ebc205e43ae65000ee00addb', + 'user_id' => '1', + 'application_token' => $applicationToken, + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + // Should not throw any exception + $this->factory->validate($event, $applicationToken); + $this->assertTrue(true); // If we reach here, validation passed + } + + #[Test] + #[TestDox('validate() should throw WrongSecuritySignatureException when tokens do not match')] + public function testValidateThrowsExceptionWithMismatchedToken(): void + { + $storedToken = 'stored_application_token_12345'; + $eventToken = 'different_application_token_67890'; + + $rawRequest = $this->buildRawRequest([ + 'event' => 'ONCRMCONTACTADD', + 'event_handler_id' => '196', + 'data' => ['FIELDS' => ['ID' => '264442']], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => $eventToken, + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + $this->expectException(WrongSecuritySignatureException::class); + $this->expectExceptionMessage('Wrong security signature for event ONCRMCONTACTADD'); + $this->factory->validate($event, $storedToken); + } + + #[Test] + #[TestDox('validate() should throw InvalidArgumentException when application token is empty')] + public function testValidateThrowsExceptionWithEmptyToken(): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => 'ONCRMCONTACTADD', + 'event_handler_id' => '196', + 'data' => ['FIELDS' => ['ID' => '264442']], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => 'test_app_token', + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('application token cannot be empty string'); + $this->factory->validate($event, ''); // @phpstan-ignore argument.type + } + + #[Test] + #[TestDox('validate() should skip validation for OnApplicationInstall events')] + public function testValidateSkipsCheckForApplicationInstallEvent(): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => 'ONAPPINSTALL', + 'event_handler_id' => '1', + 'data' => [ + 'VERSION' => '1', + 'LANGUAGE_ID' => 'en', + ], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm,placement,user_brief', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => 'event_app_token', + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + // Should not throw exception even with different token + $this->factory->validate($event, 'completely_different_token'); + $this->assertTrue(true); // If we reach here, validation was skipped + } + + #[Test] + #[TestDox('validate() should still require non-empty token for OnApplicationInstall events')] + public function testValidateRequiresNonEmptyTokenForApplicationInstallEvent(): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => 'ONAPPINSTALL', + 'event_handler_id' => '1', + 'data' => [ + 'VERSION' => '1', + 'LANGUAGE_ID' => 'en', + ], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm,placement,user_brief', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => 'event_app_token', + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('application token cannot be empty string'); + $this->factory->validate($event, ''); // @phpstan-ignore argument.type + } + + #[Test] + #[TestDox('validate() should handle various event types with correct tokens')] + #[DataProvider('validTokenProvider')] + public function testValidateWithVariousEventTypes(string $eventCode, string $applicationToken): void + { + $rawRequest = $this->buildRawRequest([ + 'event' => $eventCode, + 'event_handler_id' => '1', + 'data' => ['FIELDS' => ['ID' => '1']], + 'ts' => '1762089975', + 'auth' => [ + 'access_token' => 'test_access_token', + 'expires' => '1762093575', + 'expires_in' => '3600', + 'scope' => 'crm', + 'domain' => 'test.bitrix24.com', + 'server_endpoint' => 'https://oauth.bitrix.info/rest/', + 'status' => 'L', + 'client_endpoint' => 'https://test.bitrix24.com/rest/', + 'member_id' => 'test_member_id', + 'user_id' => '1', + 'application_token' => $applicationToken, + ], + ]); + + $request = $this->createRequest($rawRequest); + $event = $this->factory->create($request); + + // Should not throw exception + $this->factory->validate($event, $applicationToken); + $this->assertTrue(true); + } + + // ==================== Data Providers ==================== + + public static function eventCodeCaseProvider(): Generator + { + yield 'uppercase ONCRMCONTACTADD' => [ + 'ONCRMCONTACTADD', + OnCrmContactAdd::class, + ]; + + yield 'uppercase ONAPPINSTALL' => [ + 'ONAPPINSTALL', + OnApplicationInstall::class, + ]; + + yield 'unknown event code' => [ + 'UNKNOWNEVENT', + UnsupportedRemoteEvent::class, + ]; + } + + public static function validTokenProvider(): Generator + { + $token1 = 'e24831714bb347622e3ef25af61525cf'; + $token2 = '3c6c9248ec54af6bea1159b43ee0ab32'; + $token3 = 'test_application_token_12345'; + + yield 'CRM Contact Add with token 1' => ['ONCRMCONTACTADD', $token1]; + yield 'CRM Contact Update with token 2' => ['ONCRMCONTACTUPDATE', $token2]; + yield 'CRM Contact Delete with token 3' => ['ONCRMCONTACTDELETE', $token3]; + yield 'CRM Company Add with token 1' => ['ONCRMCOMPANYADD', $token1]; + } + + // ==================== Helper Methods ==================== + /** + * Builds a URL-encoded request string from an array of parameters. + * + * @param array $params + */ + private function buildRawRequest(array $params): string + { + return http_build_query($params); + } + + /** + * Creates a Symfony Request object from raw request content. + */ + private function createRequest(string $rawRequest): Request + { + // Parse the raw request string into an array for POST parameters + parse_str($rawRequest, $requestContent); + + $request = new Request( + [], // GET parameters + $requestContent, // POST parameters (parsed from raw request) + [], // attributes + [], // cookies + [], // files + [], // server + $rawRequest // raw content + ); + $request->setMethod('POST'); + + return $request; + } +} From af0ad57ebed95af90f3063fb9009218783da11cc Mon Sep 17 00:00:00 2001 From: mesilov Date: Mon, 3 Nov 2025 18:55:46 +0600 Subject: [PATCH 11/33] Refactor `RemoteEventsFactory`: update `validate` method to use `Bitrix24AccountInterface` for token validation logic. Adjust unit tests to reflect changes and improve coverage. Signed-off-by: mesilov --- src/Services/RemoteEventsFactory.php | 26 ++--- .../Unit/Services/RemoteEventsFactoryTest.php | 102 +++++------------- 2 files changed, 43 insertions(+), 85 deletions(-) diff --git a/src/Services/RemoteEventsFactory.php b/src/Services/RemoteEventsFactory.php index e273cd28..c97cf84b 100644 --- a/src/Services/RemoteEventsFactory.php +++ b/src/Services/RemoteEventsFactory.php @@ -13,6 +13,7 @@ namespace Bitrix24\SDK\Services; +use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountInterface; use Bitrix24\SDK\Application\Requests\Events\ApplicationLifeCycleEventsFactory; use Bitrix24\SDK\Application\Requests\Events\OnApplicationInstall\OnApplicationInstall; use Bitrix24\SDK\Core\Contracts\Events\EventInterface; @@ -93,17 +94,14 @@ public function create(Request $request): EventInterface } /** - * @param EventInterface $event - * @param non-empty-string $applicationToken + * Check event security signature for incoming event + * + * All events MUST validate for application_token signature + * @see https://apidocs.bitrix24.com/api-reference/events/safe-event-handlers.html * @throws WrongSecuritySignatureException - * @throws InvalidArgumentException */ - public function validate(EventInterface $event, string $applicationToken): void + public function validate(Bitrix24AccountInterface $bitrix24Account, EventInterface $event): void { - if ($applicationToken === '') { - throw new InvalidArgumentException('application token cannot be empty string'); - } - if ($event instanceof OnApplicationInstall) { // skip OnApplicationInstall event check because application_token is null // first event in application lifecycle is OnApplicationInstall and this event contains application_token @@ -113,18 +111,22 @@ public function validate(EventInterface $event, string $applicationToken): void // check event security signature // see https://apidocs.bitrix24.com/api-reference/events/safe-event-handlers.html // all next events MUST validate for application_token signature - if ($applicationToken !== $event->getAuth()->application_token) { + if (!$bitrix24Account->isApplicationTokenValid($event->getAuth()->application_token)) { $this->logger->warning('RemoteEventsFactory.validate.eventNotValidSignature', [ 'eventCode' => $event->getEventCode(), - 'storedApplicationToken' => $applicationToken, + 'storedApplicationToken' => $bitrix24Account, 'eventApplicationToken' => $event->getAuth()->application_token, 'eventPayload' => $event->getEventPayload(), + 'accountId' => $bitrix24Account->getId()->toRfc4122(), + 'memberId' => $bitrix24Account->getMemberId(), + 'domainUrl' => $bitrix24Account->getDomainUrl(), ]); throw new WrongSecuritySignatureException( sprintf( - 'Wrong security signature for event %s', - $event->getEventCode() + 'Wrong security signature for event %s processed by bitrix24 account %s', + $event->getEventCode(), + $bitrix24Account->getDomainUrl() ) ); } diff --git a/tests/Unit/Services/RemoteEventsFactoryTest.php b/tests/Unit/Services/RemoteEventsFactoryTest.php index 11082713..5d330841 100644 --- a/tests/Unit/Services/RemoteEventsFactoryTest.php +++ b/tests/Unit/Services/RemoteEventsFactoryTest.php @@ -13,6 +13,7 @@ namespace Bitrix24\SDK\Tests\Unit\Services; +use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountInterface; use Bitrix24\SDK\Application\Requests\Events\OnApplicationInstall\OnApplicationInstall; use Bitrix24\SDK\Core\Contracts\Events\EventInterface; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; @@ -217,8 +218,14 @@ public function testValidatePassesWithMatchingToken(): void $request = $this->createRequest($rawRequest); $event = $this->factory->create($request); + // Create mock account that validates the token as correct + $accountMock = $this->createMock(Bitrix24AccountInterface::class); + $accountMock->method('isApplicationTokenValid') + ->with($applicationToken) + ->willReturn(true); + // Should not throw any exception - $this->factory->validate($event, $applicationToken); + $this->factory->validate($accountMock, $event); $this->assertTrue(true); // If we reach here, validation passed } @@ -226,7 +233,6 @@ public function testValidatePassesWithMatchingToken(): void #[TestDox('validate() should throw WrongSecuritySignatureException when tokens do not match')] public function testValidateThrowsExceptionWithMismatchedToken(): void { - $storedToken = 'stored_application_token_12345'; $eventToken = 'different_application_token_67890'; $rawRequest = $this->buildRawRequest([ @@ -252,41 +258,15 @@ public function testValidateThrowsExceptionWithMismatchedToken(): void $request = $this->createRequest($rawRequest); $event = $this->factory->create($request); + // Create mock account that validates the token as incorrect + $accountMock = $this->createMock(Bitrix24AccountInterface::class); + $accountMock->method('isApplicationTokenValid') + ->with($eventToken) + ->willReturn(false); + $this->expectException(WrongSecuritySignatureException::class); $this->expectExceptionMessage('Wrong security signature for event ONCRMCONTACTADD'); - $this->factory->validate($event, $storedToken); - } - - #[Test] - #[TestDox('validate() should throw InvalidArgumentException when application token is empty')] - public function testValidateThrowsExceptionWithEmptyToken(): void - { - $rawRequest = $this->buildRawRequest([ - 'event' => 'ONCRMCONTACTADD', - 'event_handler_id' => '196', - 'data' => ['FIELDS' => ['ID' => '264442']], - 'ts' => '1762089975', - 'auth' => [ - 'access_token' => 'test_access_token', - 'expires' => '1762093575', - 'expires_in' => '3600', - 'scope' => 'crm', - 'domain' => 'test.bitrix24.com', - 'server_endpoint' => 'https://oauth.bitrix.info/rest/', - 'status' => 'L', - 'client_endpoint' => 'https://test.bitrix24.com/rest/', - 'member_id' => 'test_member_id', - 'user_id' => '1', - 'application_token' => 'test_app_token', - ], - ]); - - $request = $this->createRequest($rawRequest); - $event = $this->factory->create($request); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('application token cannot be empty string'); - $this->factory->validate($event, ''); // @phpstan-ignore argument.type + $this->factory->validate($accountMock, $event); } #[Test] @@ -319,44 +299,14 @@ public function testValidateSkipsCheckForApplicationInstallEvent(): void $request = $this->createRequest($rawRequest); $event = $this->factory->create($request); - // Should not throw exception even with different token - $this->factory->validate($event, 'completely_different_token'); - $this->assertTrue(true); // If we reach here, validation was skipped - } + // Create mock account - should not be called for OnApplicationInstall + $accountMock = $this->createMock(Bitrix24AccountInterface::class); + $accountMock->expects($this->never()) + ->method('isApplicationTokenValid'); - #[Test] - #[TestDox('validate() should still require non-empty token for OnApplicationInstall events')] - public function testValidateRequiresNonEmptyTokenForApplicationInstallEvent(): void - { - $rawRequest = $this->buildRawRequest([ - 'event' => 'ONAPPINSTALL', - 'event_handler_id' => '1', - 'data' => [ - 'VERSION' => '1', - 'LANGUAGE_ID' => 'en', - ], - 'ts' => '1762089975', - 'auth' => [ - 'access_token' => 'test_access_token', - 'expires' => '1762093575', - 'expires_in' => '3600', - 'scope' => 'crm,placement,user_brief', - 'domain' => 'test.bitrix24.com', - 'server_endpoint' => 'https://oauth.bitrix.info/rest/', - 'status' => 'L', - 'client_endpoint' => 'https://test.bitrix24.com/rest/', - 'member_id' => 'test_member_id', - 'user_id' => '1', - 'application_token' => 'event_app_token', - ], - ]); - - $request = $this->createRequest($rawRequest); - $event = $this->factory->create($request); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('application token cannot be empty string'); - $this->factory->validate($event, ''); // @phpstan-ignore argument.type + // Should not throw exception and should not check token + $this->factory->validate($accountMock, $event); + $this->assertTrue(true); // If we reach here, validation was skipped } #[Test] @@ -387,8 +337,14 @@ public function testValidateWithVariousEventTypes(string $eventCode, string $app $request = $this->createRequest($rawRequest); $event = $this->factory->create($request); + // Create mock account that validates the token as correct + $accountMock = $this->createMock(Bitrix24AccountInterface::class); + $accountMock->method('isApplicationTokenValid') + ->with($applicationToken) + ->willReturn(true); + // Should not throw exception - $this->factory->validate($event, $applicationToken); + $this->factory->validate($accountMock, $event); $this->assertTrue(true); } From a2fbe68a601079bea5cb31f4d7ab65d774d9461f Mon Sep 17 00:00:00 2001 From: Maxim Yugov Date: Tue, 4 Nov 2025 18:27:23 +0300 Subject: [PATCH 12/33] Fix entity.get method --- .../Entity/Entity/Result/EntitiesResult.php | 8 +++++++- .../Entity/Entity/Service/EntityTest.php | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Services/Entity/Entity/Result/EntitiesResult.php b/src/Services/Entity/Entity/Result/EntitiesResult.php index 9b539a1e..13eaaf1e 100644 --- a/src/Services/Entity/Entity/Result/EntitiesResult.php +++ b/src/Services/Entity/Entity/Result/EntitiesResult.php @@ -25,7 +25,13 @@ class EntitiesResult extends AbstractResult public function getEntities(): array { $res = []; - foreach ($this->getCoreResponse()->getResponseData()->getResult() as $item) { + $entities = $this->getCoreResponse()->getResponseData()->getResult(); + + if (isset($entities['ID'])) { + return [new EntityItemResult($entities)]; + } + + foreach ($entities as $item) { $res[] = new EntityItemResult($item); } diff --git a/tests/Integration/Services/Entity/Entity/Service/EntityTest.php b/tests/Integration/Services/Entity/Entity/Service/EntityTest.php index b78671ae..e0ea1708 100644 --- a/tests/Integration/Services/Entity/Entity/Service/EntityTest.php +++ b/tests/Integration/Services/Entity/Entity/Service/EntityTest.php @@ -91,6 +91,22 @@ public function testGet(): void $this->assertTrue($this->sb->getEntityScope()->entity()->delete($entity)->isSuccess()); } + public function testGetEntity(): void + { + $entity = (string)time(); + $this->assertTrue( + $this->sb->getEntityScope()->entity()->add( + $entity, + 'test entity', + [] + )->isSuccess() + ); + $entities = $this->sb->getEntityScope()->entity()->get($entity)->getEntities(); + $this->assertContains($entity, array_column($entities, 'ENTITY')); + + $this->assertTrue($this->sb->getEntityScope()->entity()->delete($entity)->isSuccess()); + } + public function testRights(): void { $entity = (string)time(); From 9513251a7af54d37594d69cff4d3006470aeb3bd Mon Sep 17 00:00:00 2001 From: mesilov Date: Wed, 5 Nov 2025 01:01:10 +0600 Subject: [PATCH 13/33] Add unit tests for `UserAgentInfo` and `UTMs` classes, covering constructors, methods, and edge cases. Adjust `ContactPersonInterfaceTest` to remove redundant checks and improve consistency. Signed-off-by: mesilov --- CHANGELOG.md | 23 + .../Entity/ContactPersonInterface.php | 29 +- .../ContactPersons/Entity/FullName.php | 5 +- .../Contracts/ContactPersons/Entity/UTMs.php | 67 +++ .../ContactPersons/Entity/UserAgentInfo.php | 36 ++ .../Entity/ContactPersonInterfaceTest.php | 77 +-- ...actPersonReferenceEntityImplementation.php | 42 +- .../ContactPersons/Entity/UTMsTest.php | 390 +++++++++++++++ .../Entity/UserAgentInfoTest.php | 449 ++++++++++++++++++ 9 files changed, 1045 insertions(+), 73 deletions(-) create mode 100644 src/Application/Contracts/ContactPersons/Entity/UTMs.php create mode 100644 src/Application/Contracts/ContactPersons/Entity/UserAgentInfo.php create mode 100644 tests/Unit/Application/Contracts/ContactPersons/Entity/UTMsTest.php create mode 100644 tests/Unit/Application/Contracts/ContactPersons/Entity/UserAgentInfoTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 757a0b31..c85f0408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,29 @@ - `onCrmContactDelete` - Added separated methods `RemoteEventsFactory::create` and `RemoteEventsFactory::validate` for create and validate incoming events, [see details](https://github.com/bitrix24/b24phpsdk/issues/291) +- Added comprehensive unit tests for `RemoteEventsFactory::create` and `RemoteEventsFactory::validate` methods with 14 test cases covering: + - Event creation for supported event types (CRM Contact Add, Application Install) + - Handling of unsupported events + - Request validation + - Token validation with `Bitrix24AccountInterface` + - Special handling for `OnApplicationInstall` events +- Updated `ContactPersonInterface` implementation, [see details](https://github.com/bitrix24/b24phpsdk/issues/290) with new methods: + - Added `isEmailVerified(): bool` to check email verification status + - Added `isMobilePhoneVerified(): bool` to check mobile phone verification status + - Changed `changeEmail(?string $email)` signature (removed optional `$isEmailVerified` parameter) + - Changed `changeMobilePhone(?PhoneNumber $phoneNumber)` signature (removed optional `$isMobilePhoneVerified` parameter) + - Added `getUserAgentInfo(): UserAgentInfo` to replace separate methods for user agent data +- Added comprehensive unit tests for `UTMs` class with 28 test cases covering: + - Constructor with all, partial, and default parameters + - URL parsing with various UTM parameter combinations + - Case-insensitive parameter handling + - URL encoding and special characters + - Real-world URL examples (Google Ads, Facebook, Email, Twitter, LinkedIn, etc.) +- Added comprehensive unit tests for `UserAgentInfo` class with 33 test cases covering: + - Constructor with IP addresses (IPv4, IPv6, localhost) + - Various user agent strings (Chrome, Firefox, Safari, Edge, mobile browsers) + - UTM extraction from referrer URLs + - Real-world scenarios with complete user tracking data ### Fixed diff --git a/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php b/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php index 384c97f8..4bad318c 100644 --- a/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php +++ b/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php @@ -74,13 +74,18 @@ public function getUpdatedAt(): CarbonImmutable; */ public function getEmail(): ?string; - public function changeEmail(?string $email, ?bool $isEmailVerified = null): void; + public function changeEmail(?string $email): void; /** * @return void mark contact person email as verified (send check main) */ public function markEmailAsVerified(): void; + /** + * @return bool is email verified with send code or magic link + */ + public function isEmailVerified(): bool; + /** * @return CarbonImmutable|null is contact person email verified */ @@ -89,10 +94,15 @@ public function getEmailVerifiedAt(): ?CarbonImmutable; /** * Change mobile phone for contact person */ - public function changeMobilePhone(?PhoneNumber $phoneNumber, ?bool $isMobilePhoneVerified = null): void; + public function changeMobilePhone(?PhoneNumber $phoneNumber): void; public function getMobilePhone(): ?PhoneNumber; + /** + * @return bool is mobile phone verified with send sms code + */ + public function isMobilePhoneVerified(): bool; + /** * @return CarbonImmutable|null is contact person mobile phone verified */ @@ -133,18 +143,5 @@ public function getBitrix24PartnerId(): ?Uuid; */ public function setBitrix24PartnerId(?Uuid $uuid): void; - /** - * get user agent for contact person, use for store metadata in consent agreements facts - */ - public function getUserAgent(): ?string; - - /** - * get user agent referer for contact person use for store metadata in consent agreements facts - */ - public function getUserAgentReferer(): ?string; - - /** - * get user agent ip for contact person use for store metadata in consent agreements facts - */ - public function getUserAgentIp(): ?IP; + public function getUserAgentInfo(): UserAgentInfo; } diff --git a/src/Application/Contracts/ContactPersons/Entity/FullName.php b/src/Application/Contracts/ContactPersons/Entity/FullName.php index e1e3c6b4..5c428c48 100644 --- a/src/Application/Contracts/ContactPersons/Entity/FullName.php +++ b/src/Application/Contracts/ContactPersons/Entity/FullName.php @@ -21,8 +21,7 @@ public function __construct( public string $name, public ?string $surname = null, public ?string $patronymic = null - ) - { + ) { if ($surname !== null) { $this->surname = trim($surname); } @@ -41,4 +40,4 @@ public function __toString(): string { return sprintf('%s %s %s', $this->name, $this->surname, $this->patronymic); } -} \ No newline at end of file +} diff --git a/src/Application/Contracts/ContactPersons/Entity/UTMs.php b/src/Application/Contracts/ContactPersons/Entity/UTMs.php new file mode 100644 index 00000000..c0a04dc6 --- /dev/null +++ b/src/Application/Contracts/ContactPersons/Entity/UTMs.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\Application\Contracts\ContactPersons\Entity; + +use function parse_url; +use function parse_str; + +readonly class UTMs +{ + public function __construct( + /** + * Identifies which site sent the traffic (google, facebook, twitter, etc.) + */ + public ?string $source = null, + /** + * Identifies what type of link was used (cpc, banner, email, etc.) + */ + public ?string $medium = null, + /** + * Identifies a specific product promotion or strategic campaign + */ + public ?string $campaign = null, + /** + * Identifies search terms used by paid search campaigns + */ + public ?string $term = null, + /** + * Identifies what specifically was clicked to bring the user to the site (banner ad, text link, etc.) + */ + public ?string $content = null, + ) { + } + + /** + * Create UTMs object from URL string + */ + public static function fromUrl(string $url): self + { + $query = parse_url($url, PHP_URL_QUERY); + if ($query === null || $query === false) { + return new self(); + } + + $query = strtolower($query); + parse_str($query, $params); + + return new self( + $params['utm_source'] ?? null, + $params['utm_medium'] ?? null, + $params['utm_campaign'] ?? null, + $params['utm_term'] ?? null, + $params['utm_content'] ?? null + ); + } + +} diff --git a/src/Application/Contracts/ContactPersons/Entity/UserAgentInfo.php b/src/Application/Contracts/ContactPersons/Entity/UserAgentInfo.php new file mode 100644 index 00000000..b9aa6fab --- /dev/null +++ b/src/Application/Contracts/ContactPersons/Entity/UserAgentInfo.php @@ -0,0 +1,36 @@ + + * + * 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\Application\Contracts\ContactPersons\Entity; + +use Darsyn\IP\Version\Multi as IP; + +readonly class UserAgentInfo +{ + public function __construct( + public ?IP $ip, + public ?string $userAgent = null, + public ?string $referrer = null, + public ?string $fingerprint = null, + ) { + } + + public function getUTMs(): UTMs + { + if ($this->referrer === null) { + return new UTMs(); + } + + return UTMs::fromUrl($this->referrer); + } +} diff --git a/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php b/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php index dab78765..d0b502d8 100644 --- a/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php +++ b/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php @@ -444,20 +444,6 @@ final public function testChangeEmail( $contactPerson->changeEmail($newEmail); $this->assertEquals($newEmail, $contactPerson->getEmail()); $this->assertNull($contactPerson->getEmailVerifiedAt()); - - $newEmail = DemoDataGenerator::getEmail(); - $contactPerson->changeEmail($newEmail, true); - $this->assertEquals($newEmail, $contactPerson->getEmail()); - $this->assertNotNull($contactPerson->getEmailVerifiedAt()); - $newEmail = DemoDataGenerator::getEmail(); - $contactPerson->changeEmail($newEmail); - $this->assertEquals($newEmail, $contactPerson->getEmail()); - $this->assertNull($contactPerson->getEmailVerifiedAt()); - - $newEmail = DemoDataGenerator::getEmail(); - $contactPerson->changeEmail($newEmail, false); - $this->assertEquals($newEmail, $contactPerson->getEmail()); - $this->assertNull($contactPerson->getEmailVerifiedAt()); } #[Test] @@ -552,14 +538,6 @@ final public function testChangeMobilePhone( $phone = DemoDataGenerator::getMobilePhone(); $contactPerson->changeMobilePhone($phone); $this->assertNull($contactPerson->getMobilePhoneVerifiedAt()); - - $phone = DemoDataGenerator::getMobilePhone(); - $contactPerson->changeMobilePhone($phone, false); - $this->assertNull($contactPerson->getMobilePhoneVerifiedAt()); - - $phone = DemoDataGenerator::getMobilePhone(); - $contactPerson->changeMobilePhone($phone, true); - $this->assertNotNull($contactPerson->getMobilePhoneVerifiedAt()); } #[Test] @@ -590,7 +568,10 @@ final public function testGetMobilePhoneVerifiedAt( $this->assertEquals($phoneNumber, $contactPerson->getMobilePhone()); $phone = DemoDataGenerator::getMobilePhone(); - $contactPerson->changeMobilePhone($phone, true); + $contactPerson->changeMobilePhone($phone); + $this->assertNull($contactPerson->getMobilePhoneVerifiedAt()); + + $contactPerson->markMobilePhoneAsVerified(); $this->assertNotNull($contactPerson->getMobilePhoneVerifiedAt()); } @@ -830,8 +811,8 @@ final public function testSetBitrix24PartnerId( #[Test] #[DataProvider('contactPersonDataProvider')] - #[TestDox('test getUserAgent method')] - final public function testGetUserAgent( + #[TestDox('test isEmailVerified method')] + final public function testIsEmailVerified( Uuid $uuid, CarbonImmutable $createdAt, CarbonImmutable $updatedAt, @@ -853,13 +834,27 @@ final public function testGetUserAgent( ): void { $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerUuid, $userAgent, $userAgentReferer, $userAgentIp); - $this->assertEquals($userAgent, $contactPerson->getUserAgent()); + + if ($emailVerifiedAt !== null) { + $this->assertTrue($contactPerson->isEmailVerified()); + } else { + $this->assertFalse($contactPerson->isEmailVerified()); + } + + // Change email should reset verification + $newEmail = DemoDataGenerator::getEmail(); + $contactPerson->changeEmail($newEmail); + $this->assertFalse($contactPerson->isEmailVerified()); + + // Mark as verified + $contactPerson->markEmailAsVerified(); + $this->assertTrue($contactPerson->isEmailVerified()); } #[Test] #[DataProvider('contactPersonDataProvider')] - #[TestDox('test getUserAgentReferer method')] - final public function testGetUserAgentReferer( + #[TestDox('test isMobilePhoneVerified method')] + final public function testIsMobilePhoneVerified( Uuid $uuid, CarbonImmutable $createdAt, CarbonImmutable $updatedAt, @@ -881,13 +876,27 @@ final public function testGetUserAgentReferer( ): void { $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerUuid, $userAgent, $userAgentReferer, $userAgentIp); - $this->assertEquals($userAgentReferer, $contactPerson->getUserAgentReferer()); + + if ($mobilePhoneVerifiedAt !== null) { + $this->assertTrue($contactPerson->isMobilePhoneVerified()); + } else { + $this->assertFalse($contactPerson->isMobilePhoneVerified()); + } + + // Change phone should reset verification + $newPhone = DemoDataGenerator::getMobilePhone(); + $contactPerson->changeMobilePhone($newPhone); + $this->assertFalse($contactPerson->isMobilePhoneVerified()); + + // Mark as verified + $contactPerson->markMobilePhoneAsVerified(); + $this->assertTrue($contactPerson->isMobilePhoneVerified()); } #[Test] #[DataProvider('contactPersonDataProvider')] - #[TestDox('test getUserAgentIp method')] - final public function testGetUserAgentIp( + #[TestDox('test getUserAgentInfo method')] + final public function testGetUserAgentInfo( Uuid $uuid, CarbonImmutable $createdAt, CarbonImmutable $updatedAt, @@ -909,7 +918,11 @@ final public function testGetUserAgentIp( ): void { $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerUuid, $userAgent, $userAgentReferer, $userAgentIp); - $this->assertEquals($userAgentIp, $contactPerson->getUserAgentIp()); + $userAgentInfo = $contactPerson->getUserAgentInfo(); + + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + $this->assertEquals($userAgentReferer, $userAgentInfo->referrer); + $this->assertEquals($userAgentIp, $userAgentInfo->ip); } public static function contactPersonDataProvider(): Generator diff --git a/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php b/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php index 2a72c5ac..cb2a1333 100644 --- a/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php +++ b/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php @@ -16,6 +16,7 @@ use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\ContactPersonInterface; use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\ContactPersonStatus; use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\FullName; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UserAgentInfo; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; use Carbon\CarbonImmutable; use Darsyn\IP\Version\Multi as IP; @@ -123,14 +124,10 @@ public function getUpdatedAt(): CarbonImmutable return $this->updatedAt; } - public function changeEmail(?string $email, ?bool $isEmailVerified = null): void + public function changeEmail(?string $email): void { $this->emailVerifiedAt = null; $this->email = $email; - if ($isEmailVerified === true) { - $this->emailVerifiedAt = new CarbonImmutable(); - } - $this->updatedAt = new CarbonImmutable(); } @@ -144,20 +141,21 @@ public function getEmailVerifiedAt(): ?CarbonImmutable return $this->emailVerifiedAt; } + public function isEmailVerified(): bool + { + return $this->emailVerifiedAt instanceof \Carbon\CarbonImmutable; + } + public function markEmailAsVerified(): void { $this->emailVerifiedAt = new CarbonImmutable(); $this->updatedAt = new CarbonImmutable(); } - public function changeMobilePhone(?PhoneNumber $phoneNumber, ?bool $isMobilePhoneVerified = null): void + public function changeMobilePhone(?PhoneNumber $phoneNumber): void { $this->mobilePhoneVerifiedAt = null; $this->mobilePhone = $phoneNumber; - if ($isMobilePhoneVerified === true) { - $this->mobilePhoneVerifiedAt = new CarbonImmutable(); - } - $this->updatedAt = new CarbonImmutable(); } @@ -171,6 +169,11 @@ public function getMobilePhoneVerifiedAt(): ?CarbonImmutable return $this->mobilePhoneVerifiedAt; } + public function isMobilePhoneVerified(): bool + { + return $this->mobilePhoneVerifiedAt instanceof \Carbon\CarbonImmutable; + } + public function markMobilePhoneAsVerified(): void { $this->mobilePhoneVerifiedAt = new CarbonImmutable(); @@ -206,20 +209,15 @@ public function getBitrix24PartnerId(): ?Uuid public function setBitrix24PartnerId(?Uuid $uuid): void { $this->bitrix24PartnerUuid = $uuid; + $this->updatedAt = new CarbonImmutable(); } - public function getUserAgent(): ?string - { - return $this->userAgent; - } - - public function getUserAgentReferer(): ?string - { - return $this->userAgentReferer; - } - - public function getUserAgentIp(): ?IP + public function getUserAgentInfo(): UserAgentInfo { - return $this->userAgentIp; + return new UserAgentInfo( + ip: $this->userAgentIp, + userAgent: $this->userAgent, + referrer: $this->userAgentReferer + ); } } diff --git a/tests/Unit/Application/Contracts/ContactPersons/Entity/UTMsTest.php b/tests/Unit/Application/Contracts/ContactPersons/Entity/UTMsTest.php new file mode 100644 index 00000000..4e5c552a --- /dev/null +++ b/tests/Unit/Application/Contracts/ContactPersons/Entity/UTMsTest.php @@ -0,0 +1,390 @@ + + * + * 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\Application\Contracts\ContactPersons\Entity; + +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UTMs; +use Generator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(UTMs::class)] +class UTMsTest extends TestCase +{ + #[Test] + #[TestDox('constructor should create UTMs object with all parameters')] + public function testConstructorWithAllParameters(): void + { + $utms = new UTMs( + source: 'google', + medium: 'cpc', + campaign: 'spring_sale', + term: 'running shoes', + content: 'banner_blue' + ); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + $this->assertEquals('running shoes', $utms->term); + $this->assertEquals('banner_blue', $utms->content); + } + + #[Test] + #[TestDox('constructor should create UTMs object with default null values')] + public function testConstructorWithDefaultValues(): void + { + $utms = new UTMs(); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('constructor should create UTMs object with partial parameters')] + public function testConstructorWithPartialParameters(): void + { + $utms = new UTMs( + source: 'facebook', + medium: 'social', + campaign: 'summer_campaign' + ); + + $this->assertEquals('facebook', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertEquals('summer_campaign', $utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should parse URL with all UTM parameters')] + public function testFromUrlWithAllParameters(): void + { + $url = 'https://example.com/page?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale&utm_term=running+shoes&utm_content=banner_blue'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + $this->assertEquals('running shoes', $utms->term); + $this->assertEquals('banner_blue', $utms->content); + } + + #[Test] + #[TestDox('fromUrl should parse URL with partial UTM parameters')] + public function testFromUrlWithPartialParameters(): void + { + $url = 'https://example.com/page?utm_source=facebook&utm_medium=social'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('facebook', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should return empty UTMs for URL without query string')] + public function testFromUrlWithoutQueryString(): void + { + $url = 'https://example.com/page'; + + $utms = UTMs::fromUrl($url); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should return empty UTMs for URL without UTM parameters')] + public function testFromUrlWithoutUtmParameters(): void + { + $url = 'https://example.com/page?param1=value1¶m2=value2'; + + $utms = UTMs::fromUrl($url); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should parse URL with mixed case UTM parameters')] + public function testFromUrlWithMixedCaseParameters(): void + { + $url = 'https://example.com/page?UTM_SOURCE=Google&utm_MEDIUM=CPC&Utm_Campaign=Spring_Sale'; + + $utms = UTMs::fromUrl($url); + + // UTM parameters should be converted to lowercase + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + } + + #[Test] + #[TestDox('fromUrl should parse URL with UTM parameters and other query parameters')] + public function testFromUrlWithMixedParameters(): void + { + $url = 'https://example.com/page?id=123&utm_source=twitter&page=2&utm_medium=social&sort=date'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('twitter', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertNull($utms->campaign); + } + + #[Test] + #[TestDox('fromUrl should handle URL with fragment')] + public function testFromUrlWithFragment(): void + { + $url = 'https://example.com/page?utm_source=linkedin&utm_medium=social#section1'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('linkedin', $utms->source); + $this->assertEquals('social', $utms->medium); + } + + #[Test] + #[TestDox('fromUrl should handle URL encoded values')] + public function testFromUrlWithEncodedValues(): void + { + $url = 'https://example.com/page?utm_source=email&utm_campaign=new%20product&utm_content=top%20banner'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('email', $utms->source); + $this->assertEquals('new product', $utms->campaign); + $this->assertEquals('top banner', $utms->content); + } + + #[Test] + #[DataProvider('realWorldUrlsProvider')] + #[TestDox('fromUrl should parse real-world URLs')] + public function testFromUrlWithRealWorldUrls( + string $url, + ?string $expectedSource, + ?string $expectedMedium, + ?string $expectedCampaign, + ?string $expectedTerm, + ?string $expectedContent + ): void { + $utms = UTMs::fromUrl($url); + + $this->assertEquals($expectedSource, $utms->source); + $this->assertEquals($expectedMedium, $utms->medium); + $this->assertEquals($expectedCampaign, $utms->campaign); + $this->assertEquals($expectedTerm, $utms->term); + $this->assertEquals($expectedContent, $utms->content); + } + + #[Test] + #[TestDox('fromUrl should handle empty string')] + public function testFromUrlWithEmptyString(): void + { + $url = ''; + + $utms = UTMs::fromUrl($url); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should handle malformed URLs gracefully')] + public function testFromUrlWithMalformedUrl(): void + { + $url = 'not-a-valid-url'; + + $utms = UTMs::fromUrl($url); + + // Should return empty UTMs object without throwing exception + $this->assertInstanceOf(UTMs::class, $utms); + } + + #[Test] + #[TestDox('fromUrl should handle URL with only query string')] + public function testFromUrlWithOnlyQueryString(): void + { + $url = '?utm_source=google&utm_medium=cpc'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + } + + #[Test] + #[TestDox('fromUrl should handle Bitrix24 referrer URL example')] + public function testFromUrlWithBitrix24Example(): void + { + $url = 'https://bitrix24.com/apps/store?utm_source=bx24'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('bx24', $utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should handle duplicate UTM parameters (last one wins)')] + public function testFromUrlWithDuplicateParameters(): void + { + $url = 'https://example.com/page?utm_source=first&utm_source=second&utm_medium=email'; + + $utms = UTMs::fromUrl($url); + + // parse_str behavior: last value wins + $this->assertEquals('second', $utms->source); + $this->assertEquals('email', $utms->medium); + } + + #[Test] + #[TestDox('fromUrl should handle special characters in UTM values')] + public function testFromUrlWithSpecialCharacters(): void + { + $url = 'https://example.com/page?utm_source=email&utm_campaign=50%25+off&utm_content=red%26blue'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('email', $utms->source); + $this->assertEquals('50% off', $utms->campaign); + $this->assertEquals('red&blue', $utms->content); + } + + #[Test] + #[TestDox('UTMs object should be readonly')] + public function testUtmsIsReadonly(): void + { + $utms = new UTMs(source: 'google'); + + $reflectionClass = new \ReflectionClass($utms); + $this->assertTrue($reflectionClass->isReadOnly(), 'UTMs class should be readonly'); + } + + public static function realWorldUrlsProvider(): Generator + { + yield 'Google Ads campaign' => [ + 'https://example.com/product?utm_source=google&utm_medium=cpc&utm_campaign=black_friday_2024&utm_term=buy+shoes&utm_content=ad_variant_a', + 'google', + 'cpc', + 'black_friday_2024', + 'buy shoes', + 'ad_variant_a' + ]; + + yield 'Facebook organic post' => [ + 'https://example.com/blog/article?utm_source=facebook&utm_medium=social&utm_campaign=awareness', + 'facebook', + 'social', + 'awareness', + null, + null + ]; + + yield 'Email newsletter' => [ + 'https://example.com/landing?utm_source=newsletter&utm_medium=email&utm_campaign=monthly_digest&utm_content=header_link', + 'newsletter', + 'email', + 'monthly_digest', + null, + 'header_link' + ]; + + yield 'Twitter post' => [ + 'https://example.com/?utm_source=twitter&utm_medium=social&utm_campaign=product_launch', + 'twitter', + 'social', + 'product_launch', + null, + null + ]; + + yield 'LinkedIn sponsored content' => [ + 'https://example.com/whitepaper?utm_source=linkedin&utm_medium=paid&utm_campaign=b2b_leads&utm_content=whitepaper_cta', + 'linkedin', + 'paid', + 'b2b_leads', + null, + 'whitepaper_cta' + ]; + + yield 'Referral from partner site' => [ + 'https://example.com/signup?utm_source=partner_site&utm_medium=referral&utm_campaign=partnership_q1', + 'partner_site', + 'referral', + 'partnership_q1', + null, + null + ]; + + yield 'YouTube video description' => [ + 'https://example.com/offer?utm_source=youtube&utm_medium=video&utm_campaign=tutorial_series&utm_content=video_description', + 'youtube', + 'video', + 'tutorial_series', + null, + 'video_description' + ]; + + yield 'URL without any UTM parameters' => [ + 'https://example.com/page', + null, + null, + null, + null, + null + ]; + + yield 'URL with only utm_source' => [ + 'https://example.com/page?utm_source=instagram', + 'instagram', + null, + null, + null, + null + ]; + + yield 'Complex URL with path and multiple parameters' => [ + 'https://example.com/category/product/details?id=123&color=red&utm_source=bing&utm_medium=cpc&size=large&utm_campaign=summer_sale', + 'bing', + 'cpc', + 'summer_sale', + null, + null + ]; + } +} diff --git a/tests/Unit/Application/Contracts/ContactPersons/Entity/UserAgentInfoTest.php b/tests/Unit/Application/Contracts/ContactPersons/Entity/UserAgentInfoTest.php new file mode 100644 index 00000000..01789545 --- /dev/null +++ b/tests/Unit/Application/Contracts/ContactPersons/Entity/UserAgentInfoTest.php @@ -0,0 +1,449 @@ + + * + * 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\Application\Contracts\ContactPersons\Entity; + +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UserAgentInfo; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UTMs; +use Darsyn\IP\Version\Multi as IP; +use Generator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(UserAgentInfo::class)] +class UserAgentInfoTest extends TestCase +{ + #[Test] + #[TestDox('constructor should create UserAgentInfo with all parameters')] + public function testConstructorWithAllParameters(): void + { + $ip = IP::factory('192.168.1.1'); + $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + $referrer = 'https://google.com/search?q=test'; + $fingerprint = 'unique-browser-fingerprint-12345'; + + $userAgentInfo = new UserAgentInfo( + ip: $ip, + userAgent: $userAgent, + referrer: $referrer, + fingerprint: $fingerprint + ); + + $this->assertSame($ip, $userAgentInfo->ip); + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + $this->assertEquals($referrer, $userAgentInfo->referrer); + $this->assertEquals($fingerprint, $userAgentInfo->fingerprint); + } + + #[Test] + #[TestDox('constructor should create UserAgentInfo with only required parameter')] + public function testConstructorWithOnlyIp(): void + { + $ip = IP::factory('10.0.0.1'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertSame($ip, $userAgentInfo->ip); + $this->assertNull($userAgentInfo->userAgent); + $this->assertNull($userAgentInfo->referrer); + $this->assertNull($userAgentInfo->fingerprint); + } + + #[Test] + #[TestDox('constructor should create UserAgentInfo with null IP')] + public function testConstructorWithNullIp(): void + { + $userAgent = 'Mozilla/5.0'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: $userAgent + ); + + $this->assertNull($userAgentInfo->ip); + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + } + + #[Test] + #[TestDox('constructor should handle IPv4 addresses')] + public function testConstructorWithIpv4(): void + { + $ip = IP::factory('192.168.0.1'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + $this->assertSame($ip, $userAgentInfo->ip); + } + + #[Test] + #[TestDox('constructor should handle IPv6 addresses')] + public function testConstructorWithIpv6(): void + { + $ip = IP::factory('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + } + + #[Test] + #[TestDox('constructor should handle localhost IP')] + public function testConstructorWithLocalhostIp(): void + { + $ip = IP::factory('127.0.0.1'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + $this->assertSame($ip, $userAgentInfo->ip); + } + + #[Test] + #[DataProvider('userAgentStringsProvider')] + #[TestDox('constructor should handle various user agent strings')] + public function testConstructorWithVariousUserAgents(string $userAgent): void + { + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: $userAgent + ); + + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + } + + #[Test] + #[TestDox('getUTMs should return empty UTMs when referrer is null')] + public function testGetUTMsWithNullReferrer(): void + { + $userAgentInfo = new UserAgentInfo( + ip: IP::factory('192.168.1.1'), + userAgent: 'Mozilla/5.0' + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertInstanceOf(UTMs::class, $utms); + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('getUTMs should parse UTMs from referrer with all parameters')] + public function testGetUTMsWithFullReferrer(): void + { + $referrer = 'https://example.com/page?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale&utm_term=shoes&utm_content=banner'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + $this->assertEquals('shoes', $utms->term); + $this->assertEquals('banner', $utms->content); + } + + #[Test] + #[TestDox('getUTMs should parse UTMs from referrer with partial parameters')] + public function testGetUTMsWithPartialReferrer(): void + { + $referrer = 'https://facebook.com/post?utm_source=facebook&utm_medium=social'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals('facebook', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('getUTMs should return empty UTMs when referrer has no UTM parameters')] + public function testGetUTMsWithReferrerWithoutUtm(): void + { + $referrer = 'https://example.com/page?id=123&page=2'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + } + + #[Test] + #[TestDox('getUTMs should handle Bitrix24 referrer example')] + public function testGetUTMsWithBitrix24Referrer(): void + { + $referrer = 'https://bitrix24.com/apps/store?utm_source=bx24'; + + $userAgentInfo = new UserAgentInfo( + ip: IP::factory('192.168.1.1'), + userAgent: 'Mozilla/5.0', + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals('bx24', $utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + } + + #[Test] + #[DataProvider('referrerWithUTMsProvider')] + #[TestDox('getUTMs should parse various referrer URLs with UTMs')] + public function testGetUTMsWithVariousReferrers( + string $referrer, + ?string $expectedSource, + ?string $expectedMedium, + ?string $expectedCampaign + ): void { + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals($expectedSource, $utms->source); + $this->assertEquals($expectedMedium, $utms->medium); + $this->assertEquals($expectedCampaign, $utms->campaign); + } + + #[Test] + #[TestDox('UserAgentInfo should be readonly')] + public function testUserAgentInfoIsReadonly(): void + { + $userAgentInfo = new UserAgentInfo(ip: null); + + $reflectionClass = new \ReflectionClass($userAgentInfo); + $this->assertTrue($reflectionClass->isReadOnly(), 'UserAgentInfo class should be readonly'); + } + + #[Test] + #[TestDox('constructor should handle complete real-world scenario')] + public function testConstructorWithCompleteRealWorldScenario(): void + { + $ip = IP::factory('203.0.113.42'); + $userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'; + $referrer = 'https://google.com/search?q=bitrix24&utm_source=google&utm_medium=organic'; + $fingerprint = 'fp_a1b2c3d4e5f6'; + + $userAgentInfo = new UserAgentInfo( + ip: $ip, + userAgent: $userAgent, + referrer: $referrer, + fingerprint: $fingerprint + ); + + // Verify all properties + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + $this->assertSame($ip, $userAgentInfo->ip); + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + $this->assertEquals($referrer, $userAgentInfo->referrer); + $this->assertEquals($fingerprint, $userAgentInfo->fingerprint); + + // Verify UTMs extracted from referrer + $utms = $userAgentInfo->getUTMs(); + $this->assertEquals('google', $utms->source); + $this->assertEquals('organic', $utms->medium); + } + + #[Test] + #[TestDox('getUTMs should return same UTMs object structure on multiple calls')] + public function testGetUTMsConsistency(): void + { + $referrer = 'https://example.com?utm_source=test&utm_medium=email'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms1 = $userAgentInfo->getUTMs(); + $utms2 = $userAgentInfo->getUTMs(); + + // Different objects but same values + $this->assertNotSame($utms1, $utms2); + $this->assertEquals($utms1->source, $utms2->source); + $this->assertEquals($utms1->medium, $utms2->medium); + } + + #[Test] + #[TestDox('constructor should handle empty string values')] + public function testConstructorWithEmptyStrings(): void + { + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: '', + referrer: '', + fingerprint: '' + ); + + $this->assertEquals('', $userAgentInfo->userAgent); + $this->assertEquals('', $userAgentInfo->referrer); + $this->assertEquals('', $userAgentInfo->fingerprint); + } + + #[Test] + #[TestDox('getUTMs should handle empty referrer string')] + public function testGetUTMsWithEmptyReferrer(): void + { + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: '' + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertInstanceOf(UTMs::class, $utms); + $this->assertNull($utms->source); + } + + #[Test] + #[TestDox('constructor should handle very long user agent strings')] + public function testConstructorWithLongUserAgent(): void + { + $longUserAgent = str_repeat('Mozilla/5.0 ', 100); + + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: $longUserAgent + ); + + $this->assertEquals($longUserAgent, $userAgentInfo->userAgent); + } + + #[Test] + #[TestDox('constructor should handle special characters in fingerprint')] + public function testConstructorWithSpecialCharactersInFingerprint(): void + { + $fingerprint = 'fp_!@#$%^&*()_+-=[]{}|;:,.<>?'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + fingerprint: $fingerprint + ); + + $this->assertEquals($fingerprint, $userAgentInfo->fingerprint); + } + + public static function userAgentStringsProvider(): Generator + { + yield 'Chrome on Windows' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ]; + + yield 'Firefox on macOS' => [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/121.0' + ]; + + yield 'Safari on iPhone' => [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1' + ]; + + yield 'Edge on Windows' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0' + ]; + + yield 'Android Chrome' => [ + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' + ]; + + yield 'Opera on Linux' => [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0' + ]; + + yield 'Bot user agent' => [ + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' + ]; + + yield 'Empty user agent' => [ + '' + ]; + } + + public static function referrerWithUTMsProvider(): Generator + { + yield 'Google search with organic' => [ + 'https://google.com/search?q=bitrix24&utm_source=google&utm_medium=organic', + 'google', + 'organic', + null + ]; + + yield 'Facebook post' => [ + 'https://facebook.com/post/123?utm_source=facebook&utm_medium=social&utm_campaign=product_launch', + 'facebook', + 'social', + 'product_launch' + ]; + + yield 'Email newsletter' => [ + 'https://example.com/landing?utm_source=newsletter&utm_medium=email&utm_campaign=weekly_digest', + 'newsletter', + 'email', + 'weekly_digest' + ]; + + yield 'Twitter link' => [ + 'https://example.com/?utm_source=twitter&utm_medium=social', + 'twitter', + 'social', + null + ]; + + yield 'LinkedIn sponsored' => [ + 'https://example.com/whitepaper?utm_source=linkedin&utm_medium=paid&utm_campaign=b2b_campaign', + 'linkedin', + 'paid', + 'b2b_campaign' + ]; + + yield 'No UTM parameters' => [ + 'https://example.com/page?id=123', + null, + null, + null + ]; + + yield 'Direct visit (no query)' => [ + 'https://example.com/', + null, + null, + null + ]; + } +} From 7b9be9ca357bf7d77d921df18c1c3136aa6b69a0 Mon Sep 17 00:00:00 2001 From: mesilov Date: Wed, 5 Nov 2025 01:47:41 +0600 Subject: [PATCH 14/33] Add repository flusher integration in `ContactPersonRepositoryInterfaceTest` to ensure data consistency after save operations. Update changelog and adjust test implementations to accommodate new method. Signed-off-by: mesilov --- CHANGELOG.md | 10 +++ .../ContactPersonRepositoryInterfaceTest.php | 36 ++++++++++ ...tactPersonRepositoryImplementationTest.php | 66 ++++++++++--------- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c85f0408..bb4656b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,11 +44,21 @@ - UTM extraction from referrer URLs - Real-world scenarios with complete user tracking data +### Changed + +- **Breaking changes** in `ContactPersonInterface` method signatures: + - `changeEmail(?string $email)` - removed second parameter `?bool $isEmailVerified`. Migration path: call `markEmailAsVerified()` separately after `changeEmail()` if email needs to be verified + - `changeMobilePhone(?PhoneNumber $phoneNumber)` - removed second parameter `?bool $isMobilePhoneVerified`. Migration path: call `markMobilePhoneAsVerified()` separately after `changeMobilePhone()` if phone needs to be verified + - Replaced `getUserAgent()`, `getUserAgentReferer()`, `getUserAgentIp()` methods with single `getUserAgentInfo(): UserAgentInfo` method that returns complete user agent information object. Migration path: use `$info->userAgent`, `$info->referrer`, `$info->ip` properties instead +- Updated `RemoteEventsFactory::validate()` method signature from `validate(EventInterface $event, string $applicationToken)` to `validate(Bitrix24AccountInterface $bitrix24Account, EventInterface $event)`. Now uses `Bitrix24AccountInterface::isApplicationTokenValid()` for token validation instead of direct string comparison + + ### Fixed - Fixed wrong offset in `ItemsResult` [see details](https://github.com/bitrix24/b24phpsdk/issues/279) - Fixed wrong exception for method `crm.item.get`, now it `ItemNotFoundException` [see details](https://github.com/bitrix24/b24phpsdk/issues/282) - Fixed added type `project` in enum `PortalLicenseFamily` [see details](https://github.com/bitrix24/b24phpsdk/issues/286) +- Fixed errors in `ContactPersonRepositoryInterfaceTest`, [see details](https://github.com/bitrix24/b24phpsdk/issues/294) ### Deprecated diff --git a/tests/Application/Contracts/ContactPersons/Repository/ContactPersonRepositoryInterfaceTest.php b/tests/Application/Contracts/ContactPersons/Repository/ContactPersonRepositoryInterfaceTest.php index 66ec443a..4f23b58f 100644 --- a/tests/Application/Contracts/ContactPersons/Repository/ContactPersonRepositoryInterfaceTest.php +++ b/tests/Application/Contracts/ContactPersons/Repository/ContactPersonRepositoryInterfaceTest.php @@ -18,6 +18,7 @@ use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException; use Bitrix24\SDK\Application\Contracts\ContactPersons\Repository\ContactPersonRepositoryInterface; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use Bitrix24\SDK\Tests\Application\Contracts\TestRepositoryFlusherInterface; use Bitrix24\SDK\Tests\Builders\DemoDataGenerator; use Carbon\CarbonImmutable; use Darsyn\IP\Version\Multi as IP; @@ -56,6 +57,7 @@ abstract protected function createContactPersonImplementation( abstract protected function createContactPersonRepositoryImplementation(): ContactPersonRepositoryInterface; + abstract protected function createRepositoryFlusherImplementation(): TestRepositoryFlusherInterface; /** * @throws ContactPersonNotFoundException */ @@ -85,8 +87,11 @@ final public function testSave( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + $acc = $contactPersonRepository->getById($contactPerson->getId()); $this->assertEquals($contactPerson, $acc); } @@ -121,8 +126,11 @@ final public function testDelete( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + $contactPerson = $contactPersonRepository->getById($contactPerson->getId()); $contactPerson->markAsDeleted('soft delete account'); @@ -194,8 +202,11 @@ final public function testGetById( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + $acc = $contactPersonRepository->getById($contactPerson->getId()); $this->assertEquals($contactPerson, $acc); } @@ -256,8 +267,11 @@ final public function testFindByEmailWithHappyPath( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + $contactPersons = $contactPersonRepository->findByEmail($email); $this->assertEquals($contactPerson, $contactPersons[0]); } @@ -288,8 +302,11 @@ final public function testFindByEmailWithNonExistsEmail( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + $contactPersons = $contactPersonRepository->findByEmail('this.email.doesnt.exists@b24.com'); $this->assertEmpty($contactPersons); } @@ -320,8 +337,11 @@ final public function testFindByEmailWithDifferentStatuses( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $bitrix24PartnerId, $externalId, $bitrix24UserId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + $contactPersons = $contactPersonRepository->findByEmail($email, $contactPersonStatus); $this->assertEquals($contactPerson, $contactPersons[0]); } @@ -332,11 +352,14 @@ final public function testFindByEmailWithDifferentStatuses( final public function testFindByEmailWithVerifiedEmail(array $items): void { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); + $flusher = $this->createRepositoryFlusherImplementation(); $expectedContactPerson = null; foreach ($items as $item) { [$uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $mobilePhone, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp] = $item; $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $mobilePhone, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $contactPersonRepository->save($contactPerson); + $flusher->flush(); if (!$expectedContactPerson instanceof ContactPersonInterface) { $expectedContactPerson = $contactPerson; } @@ -354,11 +377,14 @@ final public function testFindByEmailWithVerifiedEmail(array $items): void final public function testFindByEmailWithVerifiedPhone(array $items): void { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); + $flusher = $this->createRepositoryFlusherImplementation(); + $expectedContactPerson = null; foreach ($items as $item) { [$uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp] = $item; $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); $contactPersonRepository->save($contactPerson); + $flusher->flush(); if (!$expectedContactPerson instanceof ContactPersonInterface) { $expectedContactPerson = $contactPerson; } @@ -399,11 +425,13 @@ final public function testFindByExternalId( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $externalId = Uuid::v7(); $contactPerson->setExternalId($externalId->toRfc4122()); $contactPersonRepository->save($contactPerson); + $flusher->flush(); $acc = $contactPersonRepository->findByExternalId($externalId->toRfc4122()); $this->assertEquals($contactPerson, $acc[0]); } @@ -437,8 +465,10 @@ final public function testFindByExternalIdWithNonExistsId( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); $this->assertEquals([], $contactPersonRepository->findByExternalId(Uuid::v7()->toRfc4122())); } @@ -471,8 +501,11 @@ final public function testFindByExternalIdWithEmptyId( { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); + $flusher = $this->createRepositoryFlusherImplementation(); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + $this->expectException(InvalidArgumentException::class); /** @phpstan-ignore-next-line */ $contactPersonRepository->findByExternalId(''); @@ -485,11 +518,14 @@ final public function testFindByExternalIdWithMultipleInstalls(array $items): vo { $contactPersonRepository = $this->createContactPersonRepositoryImplementation(); $expectedContactPersons = []; + $flusher = $this->createRepositoryFlusherImplementation(); $expectedExternalId = null; foreach ($items as $item) { [$uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $mobilePhone, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp] = $item; $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $mobilePhone, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerId, $userAgent, $userAgentReferer, $userAgentIp); $contactPersonRepository->save($contactPerson); + $flusher->flush(); + if ($contactPerson->getExternalId() !== null) { $expectedContactPersons[] = $contactPerson; if ($expectedExternalId === null) { diff --git a/tests/Unit/Application/Contracts/ContactPersons/Repository/InMemoryContactPersonRepositoryImplementationTest.php b/tests/Unit/Application/Contracts/ContactPersons/Repository/InMemoryContactPersonRepositoryImplementationTest.php index c62870d4..256f8e0b 100644 --- a/tests/Unit/Application/Contracts/ContactPersons/Repository/InMemoryContactPersonRepositoryImplementationTest.php +++ b/tests/Unit/Application/Contracts/ContactPersons/Repository/InMemoryContactPersonRepositoryImplementationTest.php @@ -21,6 +21,8 @@ use Bitrix24\SDK\Core\Credentials\AuthToken; use Bitrix24\SDK\Core\Credentials\Scope; use Bitrix24\SDK\Tests\Application\Contracts\ContactPersons\Repository\ContactPersonRepositoryInterfaceTest; +use Bitrix24\SDK\Tests\Application\Contracts\NullableFlusher; +use Bitrix24\SDK\Tests\Application\Contracts\TestRepositoryFlusherInterface; use Bitrix24\SDK\Tests\Integration\Fabric; use Bitrix24\SDK\Tests\Unit\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountReferenceEntityImplementation; use Bitrix24\SDK\Tests\Unit\Application\Contracts\ContactPersons\Entity\ContactPersonReferenceEntityImplementation; @@ -35,17 +37,16 @@ class InMemoryContactPersonRepositoryImplementationTest extends ContactPersonRepositoryInterfaceTest { protected function createBitrix24AccountImplementation( - Uuid $uuid, - int $bitrix24UserId, - bool $isBitrix24UserAdmin, - bool $isMasterAccount, - string $memberId, - string $domainUrl, - AuthToken $authToken, - int $applicationVersion, - Scope $applicationScope - ): Bitrix24AccountInterface - { + Uuid $uuid, + int $bitrix24UserId, + bool $isBitrix24UserAdmin, + bool $isMasterAccount, + string $memberId, + string $domainUrl, + AuthToken $authToken, + int $applicationVersion, + Scope $applicationScope + ): Bitrix24AccountInterface { return new Bitrix24AccountReferenceEntityImplementation( $uuid, $bitrix24UserId, @@ -60,26 +61,25 @@ protected function createBitrix24AccountImplementation( } protected function createContactPersonImplementation( - Uuid $uuid, - CarbonImmutable $createdAt, - CarbonImmutable $updatedAt, + Uuid $uuid, + CarbonImmutable $createdAt, + CarbonImmutable $updatedAt, ContactPersonStatus $contactPersonStatus, - string $name, - ?string $surname, - ?string $patronymic, - ?string $email, - ?CarbonImmutable $emailVerifiedAt, - ?string $comment, - ?PhoneNumber $phoneNumber, - ?CarbonImmutable $mobilePhoneVerifiedAt, - ?string $externalId, - ?int $bitrix24UserId, - ?Uuid $bitrix24PartnerId, - ?string $userAgent, - ?string $userAgentReferer, - ?IP $userAgentIp - ): ContactPersonInterface - { + string $name, + ?string $surname, + ?string $patronymic, + ?string $email, + ?CarbonImmutable $emailVerifiedAt, + ?string $comment, + ?PhoneNumber $phoneNumber, + ?CarbonImmutable $mobilePhoneVerifiedAt, + ?string $externalId, + ?int $bitrix24UserId, + ?Uuid $bitrix24PartnerId, + ?string $userAgent, + ?string $userAgentReferer, + ?IP $userAgentIp + ): ContactPersonInterface { return new ContactPersonReferenceEntityImplementation( $uuid, $createdAt, @@ -102,6 +102,12 @@ protected function createContactPersonImplementation( ); } + protected function createRepositoryFlusherImplementation(): TestRepositoryFlusherInterface + { + return new NullableFlusher(); + } + + protected function createContactPersonRepositoryImplementation(): ContactPersonRepositoryInterface { return new InMemoryContactPersonRepositoryImplementation(new NullLogger()); From daa9cdcf138b2cf627694fbf23abbc8bcca755b9 Mon Sep 17 00:00:00 2001 From: Maxim Yugov Date: Wed, 5 Nov 2025 19:54:36 +0300 Subject: [PATCH 15/33] Added info to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c58edb0..7b2157cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1060,6 +1060,7 @@ Supported in bitrix24-php-sdk methods with batch wrapper count: 22 - Fixed variable names in `Bitrix24\SDK\Services\ServiceBuilderFactory::initFromRequest`, see [wrong variable name](https://github.com/bitrix24/b24phpsdk/issues/30). - Fixed some corner cases in `Bitrix24\SDK\Core\ApiLevelErrorHandler` +- Fixed getting entity by its code, see [entity.get issue](https://github.com/bitrix24/b24phpsdk/issues/285)