diff --git a/config/xml/Bitrix24.Lib.ContactPersons.Entity.ContactPerson.dcm.xml b/config/xml/Bitrix24.Lib.ContactPersons.Entity.ContactPerson.dcm.xml
index cfd06680..e641458f 100644
--- a/config/xml/Bitrix24.Lib.ContactPersons.Entity.ContactPerson.dcm.xml
+++ b/config/xml/Bitrix24.Lib.ContactPersons.Entity.ContactPerson.dcm.xml
@@ -10,14 +10,14 @@
-
+
-
+
diff --git a/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.UserAgentInfo.dcm.xml b/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.UserAgentInfo.dcm.xml
index 7de53bcb..779b038b 100644
--- a/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.UserAgentInfo.dcm.xml
+++ b/config/xml/Bitrix24.SDK.Application.Contracts.ContactPersons.Entity.UserAgentInfo.dcm.xml
@@ -5,6 +5,6 @@
-
+
\ No newline at end of file
diff --git a/rector.php b/rector.php
index 3ce8e423..59026b68 100644
--- a/rector.php
+++ b/rector.php
@@ -15,6 +15,7 @@
use Rector\Naming\Rector\Class_\RenamePropertyToMatchTypeRector;
use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Set\ValueObject\DowngradeLevelSetList;
+use Rector\CodeQuality\Rector\Identical\FlipTypeControlToUseExclusiveTypeRector;
return RectorConfig::configure()
->withPaths([
@@ -48,5 +49,6 @@
strictBooleans: true
)
->withSkip([
- RenamePropertyToMatchTypeRector::class
+ RenamePropertyToMatchTypeRector::class,
+ FlipTypeControlToUseExclusiveTypeRector::class,
]);
\ No newline at end of file
diff --git a/src/ApplicationInstallations/Entity/ApplicationInstallation.php b/src/ApplicationInstallations/Entity/ApplicationInstallation.php
index 99568624..bda0eea2 100644
--- a/src/ApplicationInstallations/Entity/ApplicationInstallation.php
+++ b/src/ApplicationInstallations/Entity/ApplicationInstallation.php
@@ -347,7 +347,9 @@ public function linkContactPerson(Uuid $uuid): void
#[\Override]
public function unlinkContactPerson(): void
{
- $this->updatedAt = new CarbonImmutable();
+ if (null === $this->contactPersonId) {
+ return;
+ }
$this->events[] = new Events\ApplicationInstallationContactPersonUnlinkedEvent(
$this->id,
@@ -356,13 +358,14 @@ public function unlinkContactPerson(): void
);
$this->contactPersonId = null;
+ $this->updatedAt = new CarbonImmutable();
}
#[\Override]
public function linkBitrix24PartnerContactPerson(Uuid $uuid): void
{
- $this->updatedAt = new CarbonImmutable();
$this->bitrix24PartnerContactPersonId = $uuid;
+ $this->updatedAt = new CarbonImmutable();
$this->events[] = new Events\ApplicationInstallationBitrix24PartnerContactPersonLinkedEvent(
$this->id,
@@ -374,7 +377,9 @@ public function linkBitrix24PartnerContactPerson(Uuid $uuid): void
#[\Override]
public function unlinkBitrix24PartnerContactPerson(): void
{
- $this->updatedAt = new CarbonImmutable();
+ if (null === $this->bitrix24PartnerContactPersonId) {
+ return;
+ }
$this->events[] = new Events\ApplicationInstallationBitrix24PartnerContactPersonUnlinkedEvent(
$this->id,
@@ -383,13 +388,14 @@ public function unlinkBitrix24PartnerContactPerson(): void
);
$this->bitrix24PartnerContactPersonId = null;
+ $this->updatedAt = new CarbonImmutable();
}
#[\Override]
public function linkBitrix24Partner(Uuid $uuid): void
{
- $this->updatedAt = new CarbonImmutable();
$this->bitrix24PartnerId = $uuid;
+ $this->updatedAt = new CarbonImmutable();
$this->events[] = new Events\ApplicationInstallationBitrix24PartnerLinkedEvent(
$this->id,
diff --git a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php
index d6644c6a..28717da4 100644
--- a/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php
+++ b/src/ApplicationInstallations/Infrastructure/Doctrine/ApplicationInstallationRepository.php
@@ -102,6 +102,32 @@ public function findByExternalId(string $externalId): array
;
}
+ /**
+ * Get the current installation on the portal without input parameters.
+ * The system allows only one active installation per portal,
+ * therefore, the current one is interpreted as the installation with status active.
+ * If, for any reason, there are multiple, select the most recent by createdAt.
+ *
+ * @throws ApplicationInstallationNotFoundException
+ */
+ public function getCurrent(): ApplicationInstallationInterface
+ {
+ $applicationInstallation = $this->getEntityManager()->getRepository(ApplicationInstallation::class)
+ ->createQueryBuilder('appInstallation')
+ ->where('appInstallation.status = :status')
+ ->orderBy('appInstallation.createdAt', 'DESC')
+ ->setParameter('status', ApplicationInstallationStatus::active)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+
+ if (null === $applicationInstallation) {
+ throw new ApplicationInstallationNotFoundException('current active application installation not found');
+ }
+
+ return $applicationInstallation;
+ }
+
/**
* Find application installation by application token.
*
diff --git a/src/ApplicationInstallations/UseCase/InstallContactPerson/Command.php b/src/ApplicationInstallations/UseCase/InstallContactPerson/Command.php
new file mode 100644
index 00000000..d3b09b01
--- /dev/null
+++ b/src/ApplicationInstallations/UseCase/InstallContactPerson/Command.php
@@ -0,0 +1,42 @@
+validate();
+ }
+
+ private function validate(): void
+ {
+ if (null !== $this->email && !filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
+ throw new \InvalidArgumentException('Invalid email format.');
+ }
+
+ if (null !== $this->externalId && '' === trim($this->externalId)) {
+ throw new \InvalidArgumentException('External ID cannot be empty if provided.');
+ }
+
+ if ($this->bitrix24UserId <= 0) {
+ throw new \InvalidArgumentException('Bitrix24 User ID must be a positive integer.');
+ }
+ }
+}
diff --git a/src/ApplicationInstallations/UseCase/InstallContactPerson/Handler.php b/src/ApplicationInstallations/UseCase/InstallContactPerson/Handler.php
new file mode 100644
index 00000000..c29bcd6a
--- /dev/null
+++ b/src/ApplicationInstallations/UseCase/InstallContactPerson/Handler.php
@@ -0,0 +1,122 @@
+logger->info('ContactPerson.InstallContactPerson.start', [
+ 'applicationInstallationId' => $command->applicationInstallationId,
+ 'bitrix24UserId' => $command->bitrix24UserId,
+ 'bitrix24PartnerId' => $command->bitrix24PartnerId?->toRfc4122() ?? '',
+ ]);
+
+ $createdContactPersonId = '';
+
+ try {
+ if (null !== $command->mobilePhoneNumber) {
+ try {
+ $this->guardMobilePhoneNumber($command->mobilePhoneNumber);
+ } catch (InvalidArgumentException) {
+ // Ошибка уже залогирована внутри гарда.
+ // Прерываем создание контакта, но не останавливаем установку приложения.
+ return;
+ }
+ }
+
+ /** @var null|AggregateRootEventsEmitterInterface|ApplicationInstallationInterface $applicationInstallation */
+ $applicationInstallation = $this->applicationInstallationRepository->getById($command->applicationInstallationId);
+
+ $uuidV7 = Uuid::v7();
+
+ $contactPerson = new ContactPerson(
+ $uuidV7,
+ ContactPersonStatus::active,
+ $command->fullName,
+ $command->email,
+ null,
+ $command->mobilePhoneNumber,
+ null,
+ $command->comment,
+ $command->externalId,
+ $command->bitrix24UserId,
+ $command->bitrix24PartnerId,
+ $command->userAgentInfo,
+ true
+ );
+
+ $this->contactPersonRepository->save($contactPerson);
+
+ if ($contactPerson->isPartner()) {
+ $applicationInstallation->linkBitrix24PartnerContactPerson($uuidV7);
+ } else {
+ $applicationInstallation->linkContactPerson($uuidV7);
+ }
+
+ $this->applicationInstallationRepository->save($applicationInstallation);
+
+ $this->flusher->flush($contactPerson, $applicationInstallation);
+
+ $createdContactPersonId = $uuidV7->toRfc4122();
+ } catch (ApplicationInstallationNotFoundException $applicationInstallationNotFoundException) {
+ $this->logger->warning('ContactPerson.InstallContactPerson.applicationInstallationNotFound', [
+ 'applicationInstallationId' => $command->applicationInstallationId,
+ 'message' => $applicationInstallationNotFoundException->getMessage(),
+ ]);
+
+ throw $applicationInstallationNotFoundException;
+ } finally {
+ $this->logger->info('ContactPerson.InstallContactPerson.finish', [
+ 'applicationInstallationId' => $command->applicationInstallationId,
+ 'bitrix24UserId' => $command->bitrix24UserId,
+ 'bitrix24PartnerId' => $command->bitrix24PartnerId?->toRfc4122() ?? '',
+ 'contact_person_id' => $createdContactPersonId,
+ ]);
+ }
+ }
+
+ private function guardMobilePhoneNumber(PhoneNumber $mobilePhoneNumber): void
+ {
+ if (!$this->phoneNumberUtil->isValidNumber($mobilePhoneNumber)) {
+ $this->logger->warning('ContactPerson.InstallContactPerson.InvalidMobilePhoneNumber', [
+ 'mobilePhoneNumber' => (string) $mobilePhoneNumber,
+ ]);
+
+ throw new InvalidArgumentException('Invalid mobile phone number.');
+ }
+
+ if (PhoneNumberType::MOBILE !== $this->phoneNumberUtil->getNumberType($mobilePhoneNumber)) {
+ $this->logger->warning('ContactPerson.InstallContactPerson.MobilePhoneNumberMustBeMobile', [
+ 'mobilePhoneNumber' => (string) $mobilePhoneNumber,
+ ]);
+
+ throw new InvalidArgumentException('Phone number must be mobile.');
+ }
+ }
+}
diff --git a/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Command.php b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Command.php
new file mode 100644
index 00000000..2059747c
--- /dev/null
+++ b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Command.php
@@ -0,0 +1,23 @@
+validate();
+ }
+
+ private function validate(): void
+ {
+ // no-op for now, but keep a place for future checks
+ }
+}
diff --git a/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Handler.php b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Handler.php
new file mode 100644
index 00000000..f2b47c37
--- /dev/null
+++ b/src/ApplicationInstallations/UseCase/UnlinkContactPerson/Handler.php
@@ -0,0 +1,69 @@
+logger->info('ContactPerson.UnlinkContactPerson.start', [
+ 'contactPersonId' => $command->contactPersonId,
+ 'applicationInstallationId' => $command->applicationInstallationId,
+ ]);
+
+ try {
+ /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */
+ $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId);
+
+ /** @var AggregateRootEventsEmitterInterface|ApplicationInstallationInterface $applicationInstallation */
+ $applicationInstallation = $this->applicationInstallationRepository->getById($command->applicationInstallationId);
+
+ $entitiesToFlush = [];
+
+ if ($contactPerson->isPartner()) {
+ $applicationInstallation->unlinkBitrix24PartnerContactPerson();
+ } else {
+ $applicationInstallation->unlinkContactPerson();
+ }
+
+ $this->applicationInstallationRepository->save($applicationInstallation);
+ $entitiesToFlush[] = $applicationInstallation;
+
+ $contactPerson->markAsDeleted($command->comment);
+ $this->contactPersonRepository->save($contactPerson);
+ $entitiesToFlush[] = $contactPerson;
+
+ $this->flusher->flush(...$entitiesToFlush);
+ } catch (ApplicationInstallationNotFoundException|ContactPersonNotFoundException $e) {
+ $this->logger->warning('ContactPerson.UnlinkContactPerson.notFound', [
+ 'message' => $e->getMessage(),
+ ]);
+
+ throw $e;
+ } finally {
+ $this->logger->info('ContactPerson.UnlinkContactPerson.finish', [
+ 'contactPersonId' => $command->contactPersonId,
+ 'applicationInstallationId' => $command->applicationInstallationId,
+ ]);
+ }
+ }
+}
diff --git a/src/ContactPersons/Entity/ContactPerson.php b/src/ContactPersons/Entity/ContactPerson.php
index 9a57203d..5764db62 100644
--- a/src/ContactPersons/Entity/ContactPerson.php
+++ b/src/ContactPersons/Entity/ContactPerson.php
@@ -15,13 +15,12 @@
use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonEmailChangedEvent;
use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonEmailVerifiedEvent;
use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonFullNameChangedEvent;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonMobilePhoneChangedEvent;
use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonMobilePhoneVerifiedEvent;
use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
use Bitrix24\SDK\Core\Exceptions\LogicException;
use Carbon\CarbonImmutable;
use libphonenumber\PhoneNumber;
-use libphonenumber\PhoneNumberType;
-use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Uid\Uuid;
class ContactPerson extends AggregateRoot implements ContactPersonInterface
@@ -30,9 +29,9 @@ class ContactPerson extends AggregateRoot implements ContactPersonInterface
private CarbonImmutable $updatedAt;
- private ?bool $isEmailVerified = false;
+ private bool $isEmailVerified = false;
- private ?bool $isMobilePhoneVerified = false;
+ private bool $isMobilePhoneVerified = false;
public function __construct(
private readonly Uuid $id,
@@ -44,9 +43,9 @@ public function __construct(
private ?CarbonImmutable $mobilePhoneVerifiedAt,
private ?string $comment,
private ?string $externalId,
- private readonly ?int $bitrix24UserId,
+ private readonly int $bitrix24UserId,
private ?Uuid $bitrix24PartnerId,
- private readonly ?UserAgentInfo $userAgentInfo,
+ private readonly UserAgentInfo $userAgentInfo,
private readonly bool $isEmitContactPersonCreatedEvent = false,
) {
$this->createdAt = new CarbonImmutable();
@@ -157,12 +156,27 @@ public function getEmail(): ?string
return $this->email;
}
+ /**
+ * Changes the contact person's email address.
+ *
+ * If an empty string is provided (including a string containing only whitespace),
+ * it will be normalized to `null` so that the database stores `NULL` instead of an empty value.
+ */
#[\Override]
public function changeEmail(?string $email): void
{
- $this->email = $email;
+ if (null !== $email) {
+ $email = trim($email);
+ if ('' === $email) {
+ $email = null;
+ }
+ }
+ $this->email = $email;
+ $this->isEmailVerified = false;
+ $this->emailVerifiedAt = null;
$this->updatedAt = new CarbonImmutable();
+
$this->events[] = new ContactPersonEmailChangedEvent(
$this->id,
$this->updatedAt,
@@ -173,6 +187,7 @@ public function changeEmail(?string $email): void
public function markEmailAsVerified(?CarbonImmutable $verifiedAt = null): void
{
$this->isEmailVerified = true;
+
$this->emailVerifiedAt = $verifiedAt ?? new CarbonImmutable();
$this->events[] = new ContactPersonEmailVerifiedEvent(
$this->id,
@@ -180,32 +195,40 @@ public function markEmailAsVerified(?CarbonImmutable $verifiedAt = null): void
);
}
+ #[\Override]
+ public function isPartner(): bool
+ {
+ return $this->getBitrix24PartnerId() instanceof Uuid;
+ }
+
#[\Override]
public function getEmailVerifiedAt(): ?CarbonImmutable
{
return $this->emailVerifiedAt;
}
+ /**
+ * Changes the contact's mobile phone number.
+ *
+ * Note: This method does not validate the phone number.
+ * Make sure to use it through the appropriate use case,
+ * where validation is performed.
+ *
+ * If you use this method outside a use case,
+ * ensure that you pass a valid mobile phone number.
+ */
#[\Override]
public function changeMobilePhone(?PhoneNumber $phoneNumber): void
{
- if ($phoneNumber instanceof PhoneNumber) {
- $phoneUtil = PhoneNumberUtil::getInstance();
- $isValidNumber = $phoneUtil->isValidNumber($phoneNumber);
-
- if (!$isValidNumber) {
- throw new InvalidArgumentException('Invalid phone number.');
- }
-
- $numberType = $phoneUtil->getNumberType($phoneNumber);
- if (PhoneNumberType::MOBILE !== $numberType) {
- throw new InvalidArgumentException('Phone number must be mobile.');
- }
-
- $this->mobilePhoneNumber = $phoneNumber;
- }
-
+ $this->mobilePhoneNumber = $phoneNumber;
+ $this->isMobilePhoneVerified = false;
+ $this->mobilePhoneVerifiedAt = null;
$this->updatedAt = new CarbonImmutable();
+
+ $this->events[] = new ContactPersonMobilePhoneChangedEvent(
+ $this->id,
+ $this->updatedAt,
+ );
}
#[\Override]
@@ -277,12 +300,6 @@ public function setBitrix24PartnerId(?Uuid $uuid): void
$this->updatedAt = new CarbonImmutable();
}
- #[\Override]
- public function isPartner(): bool
- {
- return $this->bitrix24PartnerId instanceof Uuid;
- }
-
#[\Override]
public function isEmailVerified(): bool
{
diff --git a/src/ContactPersons/Enum/ContactPersonType.php b/src/ContactPersons/Enum/ContactPersonType.php
new file mode 100644
index 00000000..edcf52a3
--- /dev/null
+++ b/src/ContactPersons/Enum/ContactPersonType.php
@@ -0,0 +1,20 @@
+
+ *
+ * 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\Lib\ContactPersons\Enum;
+
+enum ContactPersonType: string
+{
+ case personal = 'personal';
+ case partner = 'partner';
+}
diff --git a/src/ContactPersons/Infrastructure/Doctrine/ContactPersonRepository.php b/src/ContactPersons/Infrastructure/Doctrine/ContactPersonRepository.php
index 863b4d35..4fb9b76f 100644
--- a/src/ContactPersons/Infrastructure/Doctrine/ContactPersonRepository.php
+++ b/src/ContactPersons/Infrastructure/Doctrine/ContactPersonRepository.php
@@ -9,6 +9,7 @@
use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\ContactPersonStatus;
use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException;
use Bitrix24\SDK\Application\Contracts\ContactPersons\Repository\ContactPersonRepositoryInterface;
+use Bitrix24\SDK\Application\Contracts\Events\AggregateRootEventsEmitterInterface;
use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
@@ -17,7 +18,7 @@
class ContactPersonRepository implements ContactPersonRepositoryInterface
{
- private readonly EntityRepository $repository; // Внутренний репозиторий для базовых операций
+ private readonly EntityRepository $repository;
public function __construct(private readonly EntityManagerInterface $entityManager)
{
@@ -54,6 +55,11 @@ public function delete(Uuid $uuid): void
$this->save($contactPerson);
}
+ /**
+ * @phpstan-return ContactPersonInterface&AggregateRootEventsEmitterInterface
+ *
+ * @throws ContactPersonNotFoundException
+ */
#[\Override]
public function getById(Uuid $uuid): ContactPersonInterface
{
@@ -122,7 +128,7 @@ public function findByExternalId(string $externalId, ?ContactPersonStatus $conta
$criteria = ['externalId' => $externalId];
if ($contactPersonStatus instanceof ContactPersonStatus) {
- $criteria['contactPersonStatus'] = $contactPersonStatus->name;
+ $criteria['status'] = $contactPersonStatus->name;
}
return $this->repository->findBy($criteria);
diff --git a/src/ContactPersons/UseCase/ChangeProfile/Command.php b/src/ContactPersons/UseCase/ChangeProfile/Command.php
new file mode 100644
index 00000000..0dc45ccf
--- /dev/null
+++ b/src/ContactPersons/UseCase/ChangeProfile/Command.php
@@ -0,0 +1,32 @@
+validate();
+ }
+
+ private function validate(): void
+ {
+ // Note: empty email is allowed for profile changes.
+ // If you pass an empty string (or whitespace), it will be normalized to `null`
+ // on the entity level, so the database will store `NULL` instead of an empty string.
+ if ('' !== trim($this->email) && !filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
+ throw new InvalidArgumentException('Invalid email format.');
+ }
+ }
+}
diff --git a/src/ContactPersons/UseCase/ChangeProfile/Handler.php b/src/ContactPersons/UseCase/ChangeProfile/Handler.php
new file mode 100644
index 00000000..54b8333e
--- /dev/null
+++ b/src/ContactPersons/UseCase/ChangeProfile/Handler.php
@@ -0,0 +1,96 @@
+logger->info('ContactPerson.ChangeProfile.start', [
+ 'contactPersonId' => $command->contactPersonId,
+ 'fullName' => (string) $command->fullName,
+ 'email' => $command->email,
+ 'mobilePhoneNumber' => (string) $command->mobilePhoneNumber,
+ ]);
+
+ try {
+ /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */
+ $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId);
+
+ if (!$command->fullName->equal($contactPerson->getFullName())) {
+ $contactPerson->changeFullName($command->fullName);
+ }
+
+ if ($command->email !== $contactPerson->getEmail()) {
+ $contactPerson->changeEmail($command->email);
+ }
+
+ $this->guardMobilePhoneNumber($command->mobilePhoneNumber);
+ if (!$command->mobilePhoneNumber->equals($contactPerson->getMobilePhone())) {
+ $contactPerson->changeMobilePhone($command->mobilePhoneNumber);
+ }
+
+ $this->contactPersonRepository->save($contactPerson);
+ $this->flusher->flush($contactPerson);
+
+ $this->logger->info('ContactPerson.ChangeProfile.finish', [
+ 'contactPersonId' => $contactPerson->getId()->toRfc4122(),
+ 'updatedFields' => [
+ 'fullName' => (string) $command->fullName,
+ 'email' => $command->email,
+ 'mobilePhoneNumber' => (string) $command->mobilePhoneNumber,
+ ],
+ ]);
+ } catch (ContactPersonNotFoundException $contactPersonNotFoundException) {
+ $this->logger->warning('ContactPerson.ChangeProfile.contactPersonNotFound', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ 'message' => $contactPersonNotFoundException->getMessage(),
+ ]);
+
+ throw $contactPersonNotFoundException;
+ } finally {
+ $this->logger->info('ContactPerson.ChangeProfile.finish', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ ]);
+ }
+ }
+
+ private function guardMobilePhoneNumber(PhoneNumber $mobilePhoneNumber): void
+ {
+ if (!$this->phoneNumberUtil->isValidNumber($mobilePhoneNumber)) {
+ $this->logger->warning('ContactPerson.ChangeProfile.InvalidMobilePhoneNumber', [
+ 'mobilePhoneNumber' => (string) $mobilePhoneNumber,
+ ]);
+
+ throw new InvalidArgumentException('Invalid mobile phone number.');
+ }
+
+ if (PhoneNumberType::MOBILE !== $this->phoneNumberUtil->getNumberType($mobilePhoneNumber)) {
+ $this->logger->warning('ContactPerson.ChangeProfile.MobilePhoneNumberMustBeMobile', [
+ 'mobilePhoneNumber' => (string) $mobilePhoneNumber,
+ ]);
+
+ throw new InvalidArgumentException('Phone number must be mobile.');
+ }
+ }
+}
diff --git a/src/ContactPersons/UseCase/MarkEmailAsVerified/Command.php b/src/ContactPersons/UseCase/MarkEmailAsVerified/Command.php
new file mode 100644
index 00000000..82013e56
--- /dev/null
+++ b/src/ContactPersons/UseCase/MarkEmailAsVerified/Command.php
@@ -0,0 +1,34 @@
+validate();
+ }
+
+ private function validate(): void
+ {
+ $email = trim($this->email);
+
+ // Email verification requires a real (non-empty) email address.
+ // An empty value cannot be confirmed, so we fail fast with a clear error.
+ if ('' === $email) {
+ throw new \InvalidArgumentException('Cannot confirm an empty email.');
+ }
+
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ throw new \InvalidArgumentException('Invalid email format.');
+ }
+ }
+}
diff --git a/src/ContactPersons/UseCase/MarkEmailAsVerified/Handler.php b/src/ContactPersons/UseCase/MarkEmailAsVerified/Handler.php
new file mode 100644
index 00000000..40f2fb6b
--- /dev/null
+++ b/src/ContactPersons/UseCase/MarkEmailAsVerified/Handler.php
@@ -0,0 +1,59 @@
+logger->info('ContactPerson.MarkEmailVerification.start', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ 'email' => $command->email,
+ ]);
+
+ try {
+ /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */
+ $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId);
+
+ $actualEmail = $contactPerson->getEmail();
+
+ if (mb_strtolower((string) $actualEmail) === mb_strtolower($command->email)) {
+ $contactPerson->markEmailAsVerified($command->emailVerifiedAt);
+ $this->contactPersonRepository->save($contactPerson);
+ $this->flusher->flush($contactPerson);
+ } else {
+ $this->logger->warning('ContactPerson.MarkEmailVerification.emailMismatch', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ 'actualEmail' => $actualEmail,
+ 'expectedEmail' => $command->email,
+ ]);
+ }
+ } catch (ContactPersonNotFoundException $contactPersonNotFoundException) {
+ $this->logger->warning('ContactPerson.MarkEmailVerification.contactPersonNotFound', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ 'message' => $contactPersonNotFoundException->getMessage(),
+ ]);
+
+ throw $contactPersonNotFoundException;
+ } finally {
+ $this->logger->info('ContactPerson.MarkEmailVerification.finish', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ ]);
+ }
+ }
+}
diff --git a/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Command.php b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Command.php
new file mode 100644
index 00000000..6e339c65
--- /dev/null
+++ b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Command.php
@@ -0,0 +1,22 @@
+validate();
+ }
+
+ private function validate(): void {}
+}
diff --git a/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Handler.php b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Handler.php
new file mode 100644
index 00000000..9d45766e
--- /dev/null
+++ b/src/ContactPersons/UseCase/MarkMobilePhoneAsVerified/Handler.php
@@ -0,0 +1,71 @@
+phoneNumberUtil->format($command->phone, PhoneNumberFormat::E164);
+
+ $this->logger->info('ContactPerson.MarkMobilePhoneVerification.start', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ 'phone' => $expectedMobilePhoneE164,
+ ]);
+
+ try {
+ /** @var AggregateRootEventsEmitterInterface|ContactPersonInterface $contactPerson */
+ $contactPerson = $this->contactPersonRepository->getById($command->contactPersonId);
+
+ $actualPhone = $contactPerson->getMobilePhone();
+
+ if (null !== $actualPhone && $command->phone->equals($actualPhone)) {
+ $contactPerson->markMobilePhoneAsVerified($command->phoneVerifiedAt);
+
+ $this->contactPersonRepository->save($contactPerson);
+ $this->flusher->flush($contactPerson);
+ } else {
+ // Format the current mobile phone number to the international E.164 format
+ $actualMobilePhoneE164 = $this->phoneNumberUtil->format($actualPhone, PhoneNumberFormat::E164);
+
+ $this->logger->warning('ContactPerson.MarkMobilePhoneVerification.phoneMismatch', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ 'actualPhone' => $actualMobilePhoneE164,
+ 'expectedPhone' => $expectedMobilePhoneE164,
+ ]);
+
+ return;
+ }
+ } catch (ContactPersonNotFoundException $contactPersonNotFoundException) {
+ $this->logger->warning('ContactPerson.MarkMobilePhoneVerification.contactPersonNotFound', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ 'message' => $contactPersonNotFoundException->getMessage(),
+ ]);
+
+ throw $contactPersonNotFoundException;
+ } finally {
+ $this->logger->info('ContactPerson.MarkMobilePhoneVerification.finish', [
+ 'contactPersonId' => $command->contactPersonId->toRfc4122(),
+ ]);
+ }
+ }
+}
diff --git a/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php b/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php
index 264a03a5..b36f97a1 100644
--- a/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php
+++ b/tests/Functional/ApplicationInstallations/Builders/ApplicationInstallationBuilder.php
@@ -17,11 +17,11 @@ class ApplicationInstallationBuilder
private Uuid $bitrix24AccountId;
- private readonly ?Uuid $contactPersonId;
+ private ?Uuid $contactPersonId;
- private readonly ?Uuid $bitrix24PartnerContactPersonId;
+ private ?Uuid $bitrix24PartnerContactPersonId;
- private readonly ?Uuid $bitrix24PartnerId;
+ private ?Uuid $bitrix24PartnerId = null;
private ?string $externalId = null;
@@ -43,7 +43,6 @@ public function __construct()
$this->bitrix24AccountId = Uuid::v7();
$this->bitrix24PartnerContactPersonId = Uuid::v7();
$this->contactPersonId = Uuid::v7();
- $this->bitrix24PartnerId = Uuid::v7();
$this->portalUsersCount = random_int(1, 1_000_000);
}
@@ -61,6 +60,13 @@ public function withApplicationToken(string $applicationToken): self
return $this;
}
+ public function withBitrix24PartnerId(?Uuid $uuid): self
+ {
+ $this->bitrix24PartnerId = $uuid;
+
+ return $this;
+ }
+
public function withApplicationStatusInstallation(ApplicationInstallationStatus $applicationInstallationStatus): self
{
$this->status = $applicationInstallationStatus;
@@ -82,6 +88,20 @@ public function withBitrix24AccountId(Uuid $uuid): self
return $this;
}
+ public function withContactPersonId(?Uuid $uuid): self
+ {
+ $this->contactPersonId = $uuid;
+
+ return $this;
+ }
+
+ public function withBitrix24PartnerContactPersonId(?Uuid $uuid): self
+ {
+ $this->bitrix24PartnerContactPersonId = $uuid;
+
+ return $this;
+ }
+
public function withPortalLicenseFamily(PortalLicenseFamily $portalLicenseFamily): self
{
$this->portalLicenseFamily = $portalLicenseFamily;
diff --git a/tests/Functional/ApplicationInstallations/UseCase/InstallContactPerson/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/InstallContactPerson/HandlerTest.php
new file mode 100644
index 00000000..076c62b6
--- /dev/null
+++ b/tests/Functional/ApplicationInstallations/UseCase/InstallContactPerson/HandlerTest.php
@@ -0,0 +1,296 @@
+
+ *
+ * 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\Lib\Tests\Functional\ApplicationInstallations\UseCase\InstallContactPerson;
+
+use Bitrix24\Lib\ApplicationInstallations\Infrastructure\Doctrine\ApplicationInstallationRepository;
+use Bitrix24\Lib\ApplicationInstallations\UseCase\InstallContactPerson\Command;
+use Bitrix24\Lib\ApplicationInstallations\UseCase\InstallContactPerson\Handler;
+use Bitrix24\Lib\Bitrix24Accounts\Infrastructure\Doctrine\Bitrix24AccountRepository;
+use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository;
+use Bitrix24\Lib\Services\Flusher;
+use Bitrix24\Lib\Tests\EntityManagerFactory;
+use Bitrix24\Lib\Tests\Functional\ApplicationInstallations\Builders\ApplicationInstallationBuilder;
+use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder;
+use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder;
+use Bitrix24\SDK\Application\ApplicationStatus;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationStatus;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationContactPersonLinkedEvent;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException;
+use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonCreatedEvent;
+use Bitrix24\SDK\Application\PortalLicenseFamily;
+use Bitrix24\SDK\Core\Credentials\Scope;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\Stopwatch\Stopwatch;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * @internal
+ */
+#[CoversClass(Handler::class)]
+class HandlerTest extends TestCase
+{
+ /**
+ * @var PhoneNumberUtil
+ */
+ public $phoneNumberUtil;
+
+ private Handler $handler;
+
+ private Flusher $flusher;
+
+ private ContactPersonRepository $repository;
+
+ private ApplicationInstallationRepository $applicationInstallationRepository;
+
+ private Bitrix24AccountRepository $bitrix24accountRepository;
+
+ private TraceableEventDispatcher $eventDispatcher;
+
+ #[\Override]
+ protected function setUp(): void
+ {
+ $entityManager = EntityManagerFactory::get();
+ $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch());
+ $this->repository = new ContactPersonRepository($entityManager);
+ $this->applicationInstallationRepository = new ApplicationInstallationRepository($entityManager);
+ $this->bitrix24accountRepository = new Bitrix24AccountRepository($entityManager);
+ $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
+ $this->flusher = new Flusher($entityManager, $this->eventDispatcher);
+ $this->handler = new Handler(
+ $this->applicationInstallationRepository,
+ $this->repository,
+ $this->phoneNumberUtil,
+ $this->flusher,
+ new NullLogger()
+ );
+ }
+
+ #[Test]
+ public function testInstallContactPersonSuccess(): void
+ {
+ // Подготовка Bitrix24 аккаунта и установки приложения
+ $applicationToken = Uuid::v7()->toRfc4122();
+ $memberId = Uuid::v4()->toRfc4122();
+ $externalId = Uuid::v7()->toRfc4122();
+
+ $bitrix24Account = (new Bitrix24AccountBuilder())
+ ->withApplicationScope(new Scope(['crm']))
+ ->withStatus(Bitrix24AccountStatus::new)
+ ->withApplicationToken($applicationToken)
+ ->withMemberId($memberId)
+ ->withMaster(true)
+ ->withSetToken()
+ ->withInstalled()
+ ->build()
+ ;
+
+ $this->bitrix24accountRepository->save($bitrix24Account);
+
+ $applicationInstallation = (new ApplicationInstallationBuilder())
+ ->withApplicationStatus(new ApplicationStatus('F'))
+ ->withPortalLicenseFamily(PortalLicenseFamily::free)
+ ->withBitrix24AccountId($bitrix24Account->getId())
+ ->withApplicationStatusInstallation(ApplicationInstallationStatus::active)
+ ->withApplicationToken($applicationToken)
+ ->withContactPersonId(null)
+ ->withBitrix24PartnerContactPersonId(null)
+ ->withExternalId($externalId)
+ ->build()
+ ;
+
+ $this->applicationInstallationRepository->save($applicationInstallation);
+ $this->flusher->flush();
+
+ // Данные контакта
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Test comment')
+ ->withExternalId($externalId)
+ ->withBitrix24UserId($bitrix24Account->getBitrix24UserId())
+ ->withBitrix24PartnerId($applicationInstallation->getBitrix24PartnerId())
+ ->build()
+ ;
+
+ // Запуск use-case
+ $this->handler->handle(
+ new Command(
+ $applicationInstallation->getId(),
+ $contactPerson->getFullName(),
+ $bitrix24Account->getBitrix24UserId(),
+ $contactPerson->getUserAgentInfo(),
+ $contactPerson->getEmail(),
+ $contactPerson->getMobilePhone(),
+ $contactPerson->getComment(),
+ $contactPerson->getExternalId(),
+ $contactPerson->getBitrix24PartnerId(),
+ )
+ );
+
+ // Проверки: событие, связь и наличие контакта
+ $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents();
+ $this->assertContains(ContactPersonCreatedEvent::class, $dispatchedEvents);
+ $this->assertContains(ApplicationInstallationContactPersonLinkedEvent::class, $dispatchedEvents);
+
+ $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId());
+ $contactPersonId = $foundInstallation->getContactPersonId();
+ $this->assertNotNull($contactPersonId);
+
+ $foundContactPerson = $this->repository->getById($contactPersonId);
+ $this->assertEquals($contactPersonId, $foundContactPerson->getId());
+ $this->assertEquals($contactPerson->getEmail(), $foundContactPerson->getEmail());
+ $this->assertEquals($contactPerson->getMobilePhone(), $foundContactPerson->getMobilePhone());
+ $this->assertEquals($contactPerson->getFullName(), $foundContactPerson->getFullName());
+ $this->assertEquals($contactPerson->getComment(), $foundContactPerson->getComment());
+ $this->assertEquals($contactPerson->getExternalId(), $foundContactPerson->getExternalId());
+ $this->assertEquals($contactPerson->getBitrix24UserId(), $foundContactPerson->getBitrix24UserId());
+ $this->assertEquals($contactPerson->getBitrix24PartnerId(), $foundContactPerson->getBitrix24PartnerId());
+ }
+
+ #[Test]
+ public function testInstallContactPersonWithWrongApplicationInstallationId(): void
+ {
+ // Подготовим входные данные контакта (без реальной установки)
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Test comment')
+ ->withExternalId(Uuid::v7()->toRfc4122())
+ ->build()
+ ;
+
+ $uuidV7 = Uuid::v7();
+
+ $this->expectException(ApplicationInstallationNotFoundException::class);
+
+ $this->handler->handle(
+ new Command(
+ $uuidV7,
+ $contactPerson->getFullName(),
+ random_int(1, 1_000_000),
+ $contactPerson->getUserAgentInfo(),
+ $contactPerson->getEmail(),
+ $contactPerson->getMobilePhone(),
+ $contactPerson->getComment(),
+ $contactPerson->getExternalId(),
+ $contactPerson->getBitrix24PartnerId(),
+ )
+ );
+ }
+
+ #[Test]
+ public function testInstallContactPersonWithInvalidEmail(): void
+ {
+ // Подготовим входные данные контакта
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail('invalid-email')
+ ->build()
+ ;
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid email format.');
+
+ new Command(
+ Uuid::v7(),
+ $contactPerson->getFullName(),
+ 1,
+ $contactPerson->getUserAgentInfo(),
+ $contactPerson->getEmail(),
+ $contactPerson->getMobilePhone(),
+ $contactPerson->getComment(),
+ $contactPerson->getExternalId(),
+ $contactPerson->getBitrix24PartnerId(),
+ );
+ }
+
+ #[Test]
+ #[DataProvider('invalidPhoneProvider')]
+ public function testInstallContactPersonWithInvalidPhone(string $phoneNumber, string $region): void
+ {
+ // Подготовка Bitrix24 аккаунта и установки приложения
+ $applicationToken = Uuid::v7()->toRfc4122();
+ $memberId = Uuid::v7()->toRfc4122();
+
+ $bitrix24Account = (new Bitrix24AccountBuilder())
+ ->withApplicationToken($applicationToken)
+ ->withMemberId($memberId)
+ ->build()
+ ;
+ $this->bitrix24accountRepository->save($bitrix24Account);
+
+ $applicationInstallation = (new ApplicationInstallationBuilder())
+ ->withBitrix24AccountId($bitrix24Account->getId())
+ ->withApplicationToken($applicationToken)
+ ->withApplicationStatus(new ApplicationStatus('F'))
+ ->withPortalLicenseFamily(PortalLicenseFamily::free)
+ ->build()
+ ;
+ $this->applicationInstallationRepository->save($applicationInstallation);
+ $this->flusher->flush();
+
+ $invalidPhoneNumber = $this->phoneNumberUtil->parse($phoneNumber, $region);
+
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($invalidPhoneNumber)
+ ->build()
+ ;
+
+ $this->handler->handle(
+ new Command(
+ $applicationInstallation->getId(),
+ $contactPerson->getFullName(),
+ $bitrix24Account->getBitrix24UserId(),
+ $contactPerson->getUserAgentInfo(),
+ $contactPerson->getEmail(),
+ $contactPerson->getMobilePhone(),
+ $contactPerson->getComment(),
+ $contactPerson->getExternalId(),
+ $contactPerson->getBitrix24PartnerId(),
+ )
+ );
+
+ // Проверяем, что контакт не был создан
+ $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId());
+ $this->assertNull($foundInstallation->getBitrix24PartnerId());
+ }
+
+ public static function invalidPhoneProvider(): array
+ {
+ return [
+ 'invalid format' => ['123', 'RU'],
+ 'not mobile' => ['+74951234567', 'RU'], // Moscow landline
+ ];
+ }
+
+ private function createPhoneNumber(string $number): PhoneNumber
+ {
+ $phoneNumberUtil = PhoneNumberUtil::getInstance();
+
+ return $phoneNumberUtil->parse($number, 'RU');
+ }
+}
diff --git a/tests/Functional/ApplicationInstallations/UseCase/UnlinkContactPerson/HandlerTest.php b/tests/Functional/ApplicationInstallations/UseCase/UnlinkContactPerson/HandlerTest.php
new file mode 100644
index 00000000..ee82d512
--- /dev/null
+++ b/tests/Functional/ApplicationInstallations/UseCase/UnlinkContactPerson/HandlerTest.php
@@ -0,0 +1,370 @@
+
+ *
+ * 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\Lib\Tests\Functional\ApplicationInstallations\UseCase\UnlinkContactPerson;
+
+use Bitrix24\Lib\ApplicationInstallations\Infrastructure\Doctrine\ApplicationInstallationRepository;
+use Bitrix24\Lib\ApplicationInstallations\UseCase\UnlinkContactPerson\Command;
+use Bitrix24\Lib\ApplicationInstallations\UseCase\UnlinkContactPerson\Handler;
+use Bitrix24\Lib\Bitrix24Accounts\Infrastructure\Doctrine\Bitrix24AccountRepository;
+use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository;
+use Bitrix24\Lib\Services\Flusher;
+use Bitrix24\Lib\Tests\EntityManagerFactory;
+use Bitrix24\Lib\Tests\Functional\ApplicationInstallations\Builders\ApplicationInstallationBuilder;
+use Bitrix24\Lib\Tests\Functional\Bitrix24Accounts\Builders\Bitrix24AccountBuilder;
+use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder;
+use Bitrix24\SDK\Application\ApplicationStatus;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Entity\ApplicationInstallationStatus;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationBitrix24PartnerContactPersonUnlinkedEvent;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Events\ApplicationInstallationContactPersonUnlinkedEvent;
+use Bitrix24\SDK\Application\Contracts\ApplicationInstallations\Exceptions\ApplicationInstallationNotFoundException;
+use Bitrix24\SDK\Application\Contracts\Bitrix24Accounts\Entity\Bitrix24AccountStatus;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonDeletedEvent;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException;
+use Bitrix24\SDK\Application\PortalLicenseFamily;
+use Bitrix24\SDK\Core\Credentials\Scope;
+use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\Stopwatch\Stopwatch;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * @internal
+ */
+#[CoversClass(Handler::class)]
+class HandlerTest extends TestCase
+{
+ private Handler $handler;
+
+ private Flusher $flusher;
+
+ private ContactPersonRepository $repository;
+
+ private ApplicationInstallationRepository $applicationInstallationRepository;
+
+ private Bitrix24AccountRepository $bitrix24accountRepository;
+
+ private TraceableEventDispatcher $eventDispatcher;
+
+ #[\Override]
+ protected function setUp(): void
+ {
+ $this->truncateAllTables();
+ $entityManager = EntityManagerFactory::get();
+ $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch());
+ $this->repository = new ContactPersonRepository($entityManager);
+ $this->applicationInstallationRepository = new ApplicationInstallationRepository($entityManager);
+ $this->bitrix24accountRepository = new Bitrix24AccountRepository($entityManager);
+ $this->flusher = new Flusher($entityManager, $this->eventDispatcher);
+ $this->handler = new Handler(
+ $this->applicationInstallationRepository,
+ $this->repository,
+ $this->flusher,
+ new NullLogger()
+ );
+ }
+
+ /**
+ * @throws InvalidArgumentException|\Random\RandomException
+ */
+ #[Test]
+ public function testUninstallContactPersonSuccess(): void
+ {
+ // Подготовка Bitrix24 аккаунта и установки приложения
+ $applicationToken = Uuid::v7()->toRfc4122();
+ $memberId = Uuid::v4()->toRfc4122();
+ $externalId = Uuid::v7()->toRfc4122();
+
+ $bitrix24Account = (new Bitrix24AccountBuilder())
+ ->withApplicationScope(new Scope(['crm']))
+ ->withStatus(Bitrix24AccountStatus::new)
+ ->withApplicationToken($applicationToken)
+ ->withMemberId($memberId)
+ ->withMaster(true)
+ ->withSetToken()
+ ->withInstalled()
+ ->build();
+
+ $this->bitrix24accountRepository->save($bitrix24Account);
+
+ $applicationInstallation = (new ApplicationInstallationBuilder())
+ ->withApplicationStatus(new ApplicationStatus('F'))
+ ->withPortalLicenseFamily(PortalLicenseFamily::free)
+ ->withBitrix24AccountId($bitrix24Account->getId())
+ ->withApplicationStatusInstallation(ApplicationInstallationStatus::active)
+ ->withApplicationToken($applicationToken)
+ ->withContactPersonId(null)
+ ->withBitrix24PartnerContactPersonId(null)
+ ->withExternalId($externalId)
+ ->build();
+
+ $this->applicationInstallationRepository->save($applicationInstallation);
+
+ // Создаём контакт и привязываем к установке
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Test comment')
+ ->withExternalId($externalId)
+ ->withBitrix24UserId($bitrix24Account->getBitrix24UserId())
+ ->build();
+
+ $this->repository->save($contactPerson);
+ $applicationInstallation->linkContactPerson($contactPerson->getId());
+ $this->applicationInstallationRepository->save($applicationInstallation);
+ $this->flusher->flush();
+
+ // Запуск use-case
+ $this->handler->handle(
+ new Command(
+ $contactPerson->getId(),
+ $applicationInstallation->getId(),
+ 'Deleted by test'
+ )
+ );
+
+ // Проверки: события отвязки и удаления контакта
+ $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents();
+ $this->assertContains(ContactPersonDeletedEvent::class, $dispatchedEvents);
+ $this->assertContains(ApplicationInstallationContactPersonUnlinkedEvent::class, $dispatchedEvents);
+
+ // Перечитаем установку и проверим, что контакт отвязан
+ $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId());
+ $this->assertNull($foundInstallation->getContactPersonId());
+
+ // Контакт помечен как удалённый и недоступен через getById
+ $this->expectException(ContactPersonNotFoundException::class);
+ $this->repository->getById($contactPerson->getId());
+ }
+
+ #[Test]
+ public function testUninstallContactPersonNotFound(): void
+ {
+ // Подготовка Bitrix24 аккаунта и установки приложения (чтобы getCurrent() вернул установку)
+ $applicationToken = Uuid::v7()->toRfc4122();
+ $memberId = Uuid::v4()->toRfc4122();
+ $externalId = Uuid::v7()->toRfc4122();
+
+ $bitrix24Account = (new Bitrix24AccountBuilder())
+ ->withApplicationScope(new Scope(['crm']))
+ ->withStatus(Bitrix24AccountStatus::new)
+ ->withApplicationToken($applicationToken)
+ ->withMemberId($memberId)
+ ->withMaster(true)
+ ->withSetToken()
+ ->withInstalled()
+ ->build();
+
+ $this->bitrix24accountRepository->save($bitrix24Account);
+
+ $applicationInstallation = (new ApplicationInstallationBuilder())
+ ->withApplicationStatus(new ApplicationStatus('F'))
+ ->withPortalLicenseFamily(PortalLicenseFamily::free)
+ ->withBitrix24AccountId($bitrix24Account->getId())
+ ->withApplicationStatusInstallation(ApplicationInstallationStatus::active)
+ ->withApplicationToken($applicationToken)
+ ->withContactPersonId(null)
+ ->withBitrix24PartnerContactPersonId(null)
+ ->withExternalId($externalId)
+ ->build();
+
+ $this->applicationInstallationRepository->save($applicationInstallation);
+ $this->flusher->flush();
+
+ // Ожидаем исключение, т.к. контактного лица с таким ID нет
+ $this->expectException(ContactPersonNotFoundException::class);
+
+ $this->handler->handle(
+ new Command(
+ Uuid::v7(),
+ $applicationInstallation->getId(),
+ 'Deleted by test'
+ )
+ );
+ }
+
+ #[Test]
+ public function testUninstallContactPersonWithWrongApplicationInstallationId(): void
+ {
+ // Создадим контактное лицо, но не будем создавать установку приложения,
+ // чтобы репозиторий вернул ApplicationInstallationNotFoundException при getCurrent()
+ $externalId = Uuid::v7()->toRfc4122();
+ $contactPerson = (new ContactPersonBuilder())
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Test comment')
+ ->withExternalId($externalId)
+ ->build();
+
+ $this->repository->save($contactPerson);
+ $this->flusher->flush();
+
+ $this->expectException(ApplicationInstallationNotFoundException::class);
+
+ $this->handler->handle(
+ new Command(
+ $contactPerson->getId(),
+ Uuid::v7(),
+ 'Deleted by test'
+ )
+ );
+ }
+
+ /**
+ * @throws InvalidArgumentException|\Random\RandomException
+ */
+ #[Test]
+ public function testUninstallPartnerContactPersonSuccess(): void
+ {
+ // Подготовка Bitrix24 аккаунта и установки приложения
+ $applicationToken = Uuid::v7()->toRfc4122();
+ $memberId = Uuid::v4()->toRfc4122();
+ $externalId = Uuid::v7()->toRfc4122();
+
+ $bitrix24Account = (new Bitrix24AccountBuilder())
+ ->withApplicationScope(new Scope(['crm']))
+ ->withStatus(Bitrix24AccountStatus::new)
+ ->withApplicationToken($applicationToken)
+ ->withMemberId($memberId)
+ ->withMaster(true)
+ ->withSetToken()
+ ->withInstalled()
+ ->build();
+
+ $this->bitrix24accountRepository->save($bitrix24Account);
+
+ $applicationInstallation = (new ApplicationInstallationBuilder())
+ ->withApplicationStatus(new ApplicationStatus('F'))
+ ->withPortalLicenseFamily(PortalLicenseFamily::free)
+ ->withBitrix24AccountId($bitrix24Account->getId())
+ ->withApplicationStatusInstallation(ApplicationInstallationStatus::active)
+ ->withApplicationToken($applicationToken)
+ ->withContactPersonId(null)
+ ->withBitrix24PartnerContactPersonId(null)
+ ->withExternalId($externalId)
+ ->build();
+
+ $this->applicationInstallationRepository->save($applicationInstallation);
+
+ // Создаём контакт и привязываем как партнёрский к установке
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Test comment')
+ ->withExternalId($externalId)
+ ->withBitrix24UserId($bitrix24Account->getBitrix24UserId())
+ ->withBitrix24PartnerId(Uuid::v7())
+ ->build();
+
+ $this->repository->save($contactPerson);
+ $applicationInstallation->linkBitrix24PartnerContactPerson($contactPerson->getId());
+ $this->applicationInstallationRepository->save($applicationInstallation);
+ $this->flusher->flush();
+
+ // Запуск use-case
+ $this->handler->handle(
+ new Command(
+ $contactPerson->getId(),
+ $applicationInstallation->getId(),
+ 'Deleted by test'
+ )
+ );
+
+ // Проверки: события отвязки и удаления контакта
+ $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents();
+ $this->assertContains(ContactPersonDeletedEvent::class, $dispatchedEvents);
+ $this->assertContains(ApplicationInstallationBitrix24PartnerContactPersonUnlinkedEvent::class, $dispatchedEvents);
+
+ // Перечитаем установку и проверим, что партнёрский контакт отвязан
+ $foundInstallation = $this->applicationInstallationRepository->getById($applicationInstallation->getId());
+ $this->assertNull($foundInstallation->getBitrix24PartnerContactPersonId());
+
+ $this->expectException(ContactPersonNotFoundException::class);
+ $this->repository->getById($contactPerson->getId());
+ }
+
+ #[Test]
+ public function testUninstallPartnerContactPersonWithWrongApplicationInstallationId(): void
+ {
+ // Создадим контактное лицо, но не будем создавать установку приложения,
+ // чтобы репозиторий вернул ApplicationInstallationNotFoundException при getCurrent()
+ $externalId = Uuid::v7()->toRfc4122();
+ $contactPerson = (new ContactPersonBuilder())
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Test comment')
+ ->withExternalId($externalId)
+ ->build();
+
+ $this->repository->save($contactPerson);
+ $this->flusher->flush();
+
+ $this->expectException(ApplicationInstallationNotFoundException::class);
+
+ $this->handler->handle(
+ new Command(
+ $contactPerson->getId(),
+ Uuid::v7(),
+ 'Deleted by test'
+ )
+ );
+ }
+
+ private function createPhoneNumber(string $number): PhoneNumber
+ {
+ $phoneNumberUtil = PhoneNumberUtil::getInstance();
+ return $phoneNumberUtil->parse($number, 'RU');
+ }
+
+ private function truncateAllTables(): void
+ {
+ $entityManager = EntityManagerFactory::get();
+ $connection = $entityManager->getConnection();
+ $schemaManager = $connection->createSchemaManager();
+
+ $names = $schemaManager->introspectTableNames();
+
+ if ($names === []) {
+ return;
+ }
+
+ $quotedTables = [];
+
+ foreach ($names as $name) {
+ $tableName = $name->toString();
+ $quotedTables[] = $tableName;
+ }
+
+ $sql = 'TRUNCATE ' . implode(', ', $quotedTables) . ' RESTART IDENTITY CASCADE';
+
+ $connection->beginTransaction();
+ try {
+ $connection->executeStatement($sql);
+ $connection->commit();
+ } catch (\Throwable $throwable) {
+ $connection->rollBack();
+ throw $throwable;
+ }
+
+ $entityManager->clear();
+ }
+}
diff --git a/tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php b/tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php
new file mode 100644
index 00000000..71cf7aca
--- /dev/null
+++ b/tests/Functional/ContactPersons/Builders/ContactPersonBuilder.php
@@ -0,0 +1,131 @@
+id = Uuid::v7();
+ $this->fullName = DemoDataGenerator::getFullName();
+ $this->bitrix24UserId = random_int(1, 1_000_000);
+ }
+
+ public function withStatus(ContactPersonStatus $contactPersonStatus): self
+ {
+ $this->status = $contactPersonStatus;
+
+ return $this;
+ }
+
+ public function withFullName(FullName $fullName): self
+ {
+ $this->fullName = $fullName;
+
+ return $this;
+ }
+
+ public function withEmail(string $email): self
+ {
+ $this->email = $email;
+
+ return $this;
+ }
+
+ public function withMobilePhoneNumber(PhoneNumber $mobilePhoneNumber): self
+ {
+ $this->mobilePhoneNumber = $mobilePhoneNumber;
+
+ return $this;
+ }
+
+ public function withComment(string $comment): self
+ {
+ $this->comment = $comment;
+
+ return $this;
+ }
+
+ public function withExternalId(string $externalId): self
+ {
+ $this->externalId = $externalId;
+
+ return $this;
+ }
+
+ public function withBitrix24UserId(int $bitrix24UserId): self
+ {
+ $this->bitrix24UserId = $bitrix24UserId;
+
+ return $this;
+ }
+
+ public function withBitrix24PartnerId(?Uuid $uuid): self
+ {
+ $this->bitrix24PartnerId = $uuid;
+
+ return $this;
+ }
+
+ public function withUserAgentInfo(UserAgentInfo $userAgentInfo): self
+ {
+ $this->userAgentInfo = $userAgentInfo;
+
+ return $this;
+ }
+
+ public function build(): ContactPerson
+ {
+ $userAgentInfo = $this->userAgentInfo ?? new UserAgentInfo(
+ DemoDataGenerator::getUserAgentIp(),
+ DemoDataGenerator::getUserAgent()
+ );
+
+ return new ContactPerson(
+ $this->id,
+ $this->status,
+ $this->fullName,
+ $this->email,
+ null,
+ $this->mobilePhoneNumber,
+ null,
+ $this->comment,
+ $this->externalId,
+ $this->bitrix24UserId,
+ $this->bitrix24PartnerId,
+ $userAgentInfo
+ );
+ }
+}
\ No newline at end of file
diff --git a/tests/Functional/ContactPersons/UseCase/ChangeProfile/HandlerTest.php b/tests/Functional/ContactPersons/UseCase/ChangeProfile/HandlerTest.php
new file mode 100644
index 00000000..22373836
--- /dev/null
+++ b/tests/Functional/ContactPersons/UseCase/ChangeProfile/HandlerTest.php
@@ -0,0 +1,181 @@
+
+ *
+ * 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\Lib\Tests\Functional\ContactPersons\UseCase\ChangeProfile;
+
+use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository;
+use Bitrix24\Lib\ContactPersons\UseCase\ChangeProfile\Command;
+use Bitrix24\Lib\ContactPersons\UseCase\ChangeProfile\Handler;
+use Bitrix24\Lib\Services\Flusher;
+use Bitrix24\Lib\Tests\EntityManagerFactory;
+use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\FullName;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonEmailChangedEvent;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonFullNameChangedEvent;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Events\ContactPersonMobilePhoneChangedEvent;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberFormat;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\Stopwatch\Stopwatch;
+use Symfony\Component\Uid\Uuid;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException;
+
+/**
+ * @internal
+ */
+#[CoversClass(Handler::class)]
+class HandlerTest extends TestCase
+{
+ /**
+ * @var PhoneNumberUtil
+ */
+ public $phoneNumberUtil;
+
+ private Handler $handler;
+
+ private Flusher $flusher;
+
+ private ContactPersonRepository $repository;
+
+ private TraceableEventDispatcher $eventDispatcher;
+
+ #[\Override]
+ protected function setUp(): void
+ {
+ $entityManager = EntityManagerFactory::get();
+ $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch());
+ $this->repository = new ContactPersonRepository($entityManager);
+ $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
+ $this->flusher = new Flusher($entityManager, $this->eventDispatcher);
+ $this->handler = new Handler(
+ $this->repository,
+ $this->phoneNumberUtil,
+ $this->flusher,
+ new NullLogger()
+ );
+ }
+
+ #[Test]
+ public function testUpdateExistingContactPerson(): void
+ {
+ // Создаем контактное лицо через билдера
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Initial comment')
+ ->withExternalId(Uuid::v7()->toRfc4122())
+ ->withBitrix24UserId(random_int(1, 1_000_000))
+ ->withBitrix24PartnerId(Uuid::v7())
+ ->build()
+ ;
+
+ $this->repository->save($contactPerson);
+ $this->flusher->flush();
+
+ // Обновляем контактное лицо через команду
+ $this->handler->handle(
+ new Command(
+ $contactPerson->getId(),
+ new FullName('Jane Doe'),
+ 'jane.doe@example.com',
+ $this->createPhoneNumber('+79997654321')
+ )
+ );
+
+ // Проверяем, что изменения сохранились
+ $updatedContactPerson = $this->repository->getById($contactPerson->getId());
+ $formattedPhone = $this->phoneNumberUtil->format($updatedContactPerson->getMobilePhone(), PhoneNumberFormat::E164);
+
+ $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents();
+ $this->assertContains(ContactPersonEmailChangedEvent::class, $dispatchedEvents);
+ $this->assertContains(ContactPersonMobilePhoneChangedEvent::class, $dispatchedEvents);
+ $this->assertContains(ContactPersonFullNameChangedEvent::class, $dispatchedEvents);
+ $this->assertEquals('Jane Doe', $updatedContactPerson->getFullName()->name);
+ $this->assertEquals('jane.doe@example.com', $updatedContactPerson->getEmail());
+ $this->assertEquals('+79997654321', $formattedPhone);
+ }
+
+ #[Test]
+ public function testUpdateWithNonExistentContactPerson(): void
+ {
+ $this->expectException(ContactPersonNotFoundException::class);
+
+ $this->handler->handle(
+ new Command(
+ Uuid::v7(),
+ new FullName('Jane Doe'),
+ 'jane.doe@example.com',
+ $this->createPhoneNumber('+79997654321')
+ )
+ );
+ }
+
+ #[Test]
+ public function testUpdateWithSameData(): void
+ {
+ // Создаем контактное лицо через билдера
+ $email = 'john.doe@example.com';
+ $fullName = new FullName('John Doe');
+ $phone = '+79991234567';
+
+ $contactPersonBuilder = new ContactPersonBuilder();
+ $contactPerson = $contactPersonBuilder
+ ->withEmail($email)
+ ->withFullName($fullName)
+ ->withMobilePhoneNumber($this->createPhoneNumber($phone))
+ ->withExternalId(Uuid::v7()->toRfc4122())
+ ->withBitrix24UserId(random_int(1, 1_000_000))
+ ->withBitrix24PartnerId(Uuid::v7())
+ ->build()
+ ;
+
+ $this->repository->save($contactPerson);
+ $this->flusher->flush();
+
+ // Обновляем контактное лицо теми же данными
+ $this->handler->handle(
+ new Command(
+ $contactPerson->getId(),
+ $fullName,
+ $email,
+ $this->createPhoneNumber($phone)
+ )
+ );
+
+ // Проверяем, что события не были отправлены
+ $dispatchedEvents = $this->eventDispatcher->getOrphanedEvents();
+ $this->assertNotContains(ContactPersonEmailChangedEvent::class, $dispatchedEvents);
+ $this->assertNotContains(ContactPersonMobilePhoneChangedEvent::class, $dispatchedEvents);
+ $this->assertNotContains(ContactPersonFullNameChangedEvent::class, $dispatchedEvents);
+
+ // Проверяем, что данные не изменились
+ $updatedContactPerson = $this->repository->getById($contactPerson->getId());
+ $this->assertEquals($fullName->name, $updatedContactPerson->getFullName()->name);
+ $this->assertEquals($email, $updatedContactPerson->getEmail());
+ $this->assertEquals($phone, $this->phoneNumberUtil->format($updatedContactPerson->getMobilePhone(), PhoneNumberFormat::E164));
+ }
+
+ private function createPhoneNumber(string $number): PhoneNumber
+ {
+ $phoneNumberUtil = PhoneNumberUtil::getInstance();
+
+ return $phoneNumberUtil->parse($number, 'RU');
+ }
+}
diff --git a/tests/Functional/ContactPersons/UseCase/MarkEmailAsVerified/HandlerTest.php b/tests/Functional/ContactPersons/UseCase/MarkEmailAsVerified/HandlerTest.php
new file mode 100644
index 00000000..176627fa
--- /dev/null
+++ b/tests/Functional/ContactPersons/UseCase/MarkEmailAsVerified/HandlerTest.php
@@ -0,0 +1,148 @@
+
+ *
+ * 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\Lib\Tests\Functional\ContactPersons\UseCase\MarkEmailAsVerified;
+
+use Bitrix24\Lib\ContactPersons\Entity\ContactPerson;
+use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository;
+use Bitrix24\Lib\ContactPersons\UseCase\MarkEmailAsVerified\Command;
+use Bitrix24\Lib\ContactPersons\UseCase\MarkEmailAsVerified\Handler;
+use Bitrix24\Lib\Services\Flusher;
+use Bitrix24\Lib\Tests\EntityManagerFactory;
+use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException;
+use Carbon\CarbonImmutable;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\Stopwatch\Stopwatch;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * @internal
+ */
+#[CoversClass(Handler::class)]
+class HandlerTest extends TestCase
+{
+ private Handler $handler;
+
+ private Flusher $flusher;
+
+ private ContactPersonRepository $repository;
+
+ private TraceableEventDispatcher $eventDispatcher;
+
+ #[\Override]
+ protected function setUp(): void
+ {
+ $entityManager = EntityManagerFactory::get();
+ $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch());
+ $this->repository = new ContactPersonRepository($entityManager);
+ $this->flusher = new Flusher($entityManager, $this->eventDispatcher);
+ $this->handler = new Handler(
+ $this->repository,
+ $this->flusher,
+ new NullLogger()
+ );
+ }
+
+ #[Test]
+ public function testConfirmEmailVerificationSuccess(): void
+ {
+ $contactPerson = $this->createContactPerson('john.doe@example.com');
+
+ $verifiedAt = new CarbonImmutable('2025-01-01T10:00:00+00:00');
+ $this->handler->handle(
+ new Command($contactPerson->getId(), 'john.doe@example.com', $verifiedAt)
+ );
+
+ $updatedContactPerson = $this->repository->getById($contactPerson->getId());
+ $this->assertTrue($updatedContactPerson->isEmailVerified());
+ $this->assertSame($verifiedAt->toISOString(), $updatedContactPerson->getEmailVerifiedAt()?->toISOString());
+ }
+
+ #[Test]
+ #[DataProvider('invalidMarkEmailVerificationProvider')]
+ public function testConfirmEmailVerificationFails(
+ bool $useRealContactId,
+ string $emailInCommand,
+ ?string $expectedExceptionClass = null
+ ): void {
+ $contactPerson = $this->createContactPerson('john.doe@example.com');
+ $contactId = $useRealContactId ? $contactPerson->getId() : Uuid::v7();
+
+ if (null !== $expectedExceptionClass) {
+ $this->expectException($expectedExceptionClass);
+ }
+
+ $this->handler->handle(new Command($contactId, $emailInCommand));
+
+ if (null === $expectedExceptionClass) {
+ // Если исключение не ожидалось (например, при несовпадении email), проверяем, что статус не изменился
+ $reloaded = $this->repository->getById($contactPerson->getId());
+ $this->assertFalse($reloaded->isEmailVerified());
+ }
+ }
+
+ public static function invalidMarkEmailVerificationProvider(): array
+ {
+ return [
+ 'contact person not found' => [
+ 'useRealContactId' => false,
+ 'emailInCommand' => 'john.doe@example.com',
+ 'expectedExceptionClass' => ContactPersonNotFoundException::class,
+ ],
+ 'email mismatch' => [
+ 'useRealContactId' => true,
+ 'emailInCommand' => 'another.email@example.com',
+ 'expectedExceptionClass' => null,
+ ],
+ 'invalid email format' => [
+ 'useRealContactId' => true,
+ 'emailInCommand' => 'not-an-email',
+ 'expectedExceptionClass' => \InvalidArgumentException::class,
+ ],
+ ];
+ }
+
+ private function createContactPerson(string $email): ContactPerson
+ {
+ $contactPerson = (new ContactPersonBuilder())
+ ->withEmail($email)
+ ->withMobilePhoneNumber($this->createPhoneNumber('+79991234567'))
+ ->withComment('Test comment')
+ ->withExternalId(Uuid::v7()->toRfc4122())
+ ->withBitrix24UserId(random_int(1, 1_000_000))
+ ->withBitrix24PartnerId(Uuid::v7())
+ ->build()
+ ;
+
+ $this->repository->save($contactPerson);
+ $this->flusher->flush();
+
+ return $contactPerson;
+ }
+
+ private function createPhoneNumber(string $number): PhoneNumber
+ {
+ $phoneNumberUtil = PhoneNumberUtil::getInstance();
+
+ return $phoneNumberUtil->parse($number, 'RU');
+ }
+}
diff --git a/tests/Functional/ContactPersons/UseCase/MarkMobilePhoneAsVerified/HandlerTest.php b/tests/Functional/ContactPersons/UseCase/MarkMobilePhoneAsVerified/HandlerTest.php
new file mode 100644
index 00000000..dfe01136
--- /dev/null
+++ b/tests/Functional/ContactPersons/UseCase/MarkMobilePhoneAsVerified/HandlerTest.php
@@ -0,0 +1,158 @@
+
+ *
+ * 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\Lib\Tests\Functional\ContactPersons\UseCase\MarkMobilePhoneAsVerified;
+
+use Bitrix24\Lib\ContactPersons\Entity\ContactPerson;
+use Bitrix24\Lib\ContactPersons\Infrastructure\Doctrine\ContactPersonRepository;
+use Bitrix24\Lib\ContactPersons\UseCase\MarkMobilePhoneAsVerified\Command;
+use Bitrix24\Lib\ContactPersons\UseCase\MarkMobilePhoneAsVerified\Handler;
+use Bitrix24\Lib\Services\Flusher;
+use Bitrix24\Lib\Tests\EntityManagerFactory;
+use Bitrix24\Lib\Tests\Functional\ContactPersons\Builders\ContactPersonBuilder;
+use Bitrix24\SDK\Application\Contracts\ContactPersons\Exceptions\ContactPersonNotFoundException;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\Stopwatch\Stopwatch;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * @internal
+ */
+#[CoversClass(Handler::class)]
+class HandlerTest extends TestCase
+{
+ /**
+ * @var PhoneNumberUtil
+ */
+ public $phoneNumberUtil;
+
+ private Handler $handler;
+
+ private Flusher $flusher;
+
+ private ContactPersonRepository $repository;
+
+ private TraceableEventDispatcher $eventDispatcher;
+
+ #[\Override]
+ protected function setUp(): void
+ {
+ $entityManager = EntityManagerFactory::get();
+ $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch());
+ $this->repository = new ContactPersonRepository($entityManager);
+ $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
+ $this->flusher = new Flusher($entityManager, $this->eventDispatcher);
+ $this->handler = new Handler(
+ $this->repository,
+ $this->phoneNumberUtil,
+ $this->flusher,
+ new NullLogger()
+ );
+ }
+
+ #[Test]
+ public function testConfirmPhoneVerification(): void
+ {
+ $phoneNumber = $this->createPhoneNumber('+79991234567');
+ $contactPerson = $this->createContactPerson($phoneNumber);
+
+ $this->assertFalse($contactPerson->isMobilePhoneVerified());
+
+ $this->handler->handle(new Command($contactPerson->getId(), $phoneNumber));
+
+ $updatedContactPerson = $this->repository->getById($contactPerson->getId());
+ $this->assertTrue($updatedContactPerson->isMobilePhoneVerified());
+ }
+
+ #[Test]
+ #[DataProvider('invalidPhoneVerificationProvider')]
+ public function testConfirmPhoneVerificationFails(
+ bool $useRealContactId,
+ string $phoneNumberInCommand,
+ ?string $expectedExceptionClass = null
+ ): void {
+ $realPhoneNumber = $this->createPhoneNumber('+79991234567');
+ $contactPerson = $this->createContactPerson($realPhoneNumber);
+
+ $contactId = $useRealContactId ? $contactPerson->getId() : Uuid::v7();
+
+ if (null !== $expectedExceptionClass) {
+ $this->expectException($expectedExceptionClass);
+ }
+
+ $phoneNumber = $this->createPhoneNumber($phoneNumberInCommand);
+ $this->handler->handle(new Command($contactId, $phoneNumber));
+
+ if (null === $expectedExceptionClass) {
+ // Если исключение не ожидалось (например, при несовпадении телефона), проверяем, что статус не изменился
+ $reloaded = $this->repository->getById($contactPerson->getId());
+ $this->assertFalse($reloaded->isMobilePhoneVerified());
+ }
+ }
+
+ public static function invalidPhoneVerificationProvider(): array
+ {
+ return [
+ 'contact person not found' => [
+ 'useRealContactId' => false,
+ 'phoneNumberInCommand' => '+79991234567',
+ 'expectedExceptionClass' => ContactPersonNotFoundException::class,
+ ],
+ 'phone mismatch' => [
+ 'useRealContactId' => true,
+ 'phoneNumberInCommand' => '+79990000000',
+ 'expectedExceptionClass' => null,
+ ],
+ 'invalid phone format' => [
+ 'useRealContactId' => true,
+ 'phoneNumberInCommand' => '123',
+ 'expectedExceptionClass' => null,
+ // Actually Command doesn't validate phone format in this package, it's a PhoneNumber object.
+ // In Handler.php there's no guard for phone in MarkMobilePhoneAsVerified, it just compares them.
+ ],
+ ];
+ }
+
+ private function createContactPerson(PhoneNumber $phoneNumber): ContactPerson
+ {
+ $contactPerson = (new ContactPersonBuilder())
+ ->withEmail('john.doe@example.com')
+ ->withMobilePhoneNumber($phoneNumber)
+ ->withComment('Test comment')
+ ->withExternalId(Uuid::v7()->toRfc4122())
+ ->withBitrix24UserId(random_int(1, 1_000_000))
+ ->withBitrix24PartnerId(Uuid::v7())
+ ->build()
+ ;
+
+ $this->repository->save($contactPerson);
+ $this->flusher->flush();
+
+ return $contactPerson;
+ }
+
+ private function createPhoneNumber(string $number): PhoneNumber
+ {
+ $phoneNumberUtil = PhoneNumberUtil::getInstance();
+
+ return $phoneNumberUtil->parse($number, 'RU');
+ }
+}
diff --git a/tests/Unit/ApplicationInstallations/UseCase/InstallContactPerson/CommandTest.php b/tests/Unit/ApplicationInstallations/UseCase/InstallContactPerson/CommandTest.php
new file mode 100644
index 00000000..5df7c535
--- /dev/null
+++ b/tests/Unit/ApplicationInstallations/UseCase/InstallContactPerson/CommandTest.php
@@ -0,0 +1,156 @@
+expectException($expectedException);
+ }
+
+ $command = new Command(
+ $applicationInstallationId,
+ $fullName,
+ $bitrix24UserId,
+ $userAgentInfo,
+ $email,
+ $mobilePhoneNumber,
+ $comment,
+ $externalId,
+ $bitrix24PartnerId
+ );
+
+ self::assertSame($applicationInstallationId, $command->applicationInstallationId);
+ self::assertSame($fullName, $command->fullName);
+ self::assertSame($bitrix24UserId, $command->bitrix24UserId);
+ self::assertSame($userAgentInfo, $command->userAgentInfo);
+ self::assertSame($email, $command->email);
+ self::assertSame($mobilePhoneNumber, $command->mobilePhoneNumber);
+ self::assertSame($comment, $command->comment);
+ self::assertSame($externalId, $command->externalId);
+ self::assertSame($bitrix24PartnerId, $command->bitrix24PartnerId);
+ }
+
+ public static function commandDataProvider(): array
+ {
+ $fullName = new FullName('John Doe');
+ $userAgentInfo = new UserAgentInfo(null);
+
+ return [
+ 'valid data' => [
+ Uuid::v7(),
+ $fullName,
+ 123,
+ $userAgentInfo,
+ 'john.doe@example.com',
+ new PhoneNumber(),
+ 'Test comment',
+ 'ext-123',
+ Uuid::v7(),
+ ],
+ 'invalid email: empty' => [
+ Uuid::v7(),
+ $fullName,
+ 123,
+ $userAgentInfo,
+ '',
+ null,
+ null,
+ null,
+ null,
+ \InvalidArgumentException::class,
+ ],
+ 'invalid email: spaces' => [
+ Uuid::v7(),
+ $fullName,
+ 123,
+ $userAgentInfo,
+ ' ',
+ null,
+ null,
+ null,
+ null,
+ \InvalidArgumentException::class,
+ ],
+ 'invalid email: format' => [
+ Uuid::v7(),
+ $fullName,
+ 123,
+ $userAgentInfo,
+ 'not-an-email',
+ null,
+ null,
+ null,
+ null,
+ \InvalidArgumentException::class,
+ ],
+ 'invalid external id: empty string' => [
+ Uuid::v7(),
+ $fullName,
+ 123,
+ $userAgentInfo,
+ null,
+ null,
+ null,
+ ' ',
+ null,
+ \InvalidArgumentException::class,
+ ],
+ 'invalid user id: zero' => [
+ Uuid::v7(),
+ $fullName,
+ 0,
+ $userAgentInfo,
+ null,
+ null,
+ null,
+ null,
+ null,
+ \InvalidArgumentException::class,
+ ],
+ 'invalid user id: negative' => [
+ Uuid::v7(),
+ $fullName,
+ -1,
+ $userAgentInfo,
+ null,
+ null,
+ null,
+ null,
+ null,
+ \InvalidArgumentException::class,
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/ContactPersons/Entity/ContactPersonTest.php b/tests/Unit/ContactPersons/Entity/ContactPersonTest.php
new file mode 100644
index 00000000..03b9c38e
--- /dev/null
+++ b/tests/Unit/ContactPersons/Entity/ContactPersonTest.php
@@ -0,0 +1,61 @@
+expectException($expectedException);
+ }
+
+ $command = new Command(
+ $uuid,
+ $fullName,
+ $email,
+ $mobilePhoneNumber
+ );
+
+ self::assertEquals($uuid, $command->contactPersonId);
+ self::assertEquals($fullName, $command->fullName);
+ self::assertSame($email, $command->email);
+ self::assertEquals($mobilePhoneNumber, $command->mobilePhoneNumber);
+ }
+
+ public static function commandDataProvider(): array
+ {
+ $fullName = new FullName('John Doe');
+
+ return [
+ 'valid data' => [
+ Uuid::v7(),
+ $fullName,
+ 'john.doe@example.com',
+ new PhoneNumber(),
+ ],
+ 'empty email is valid' => [
+ Uuid::v7(),
+ $fullName,
+ '',
+ new PhoneNumber(),
+ ],
+ 'invalid email format' => [
+ Uuid::v7(),
+ $fullName,
+ 'not-an-email',
+ new PhoneNumber(),
+ InvalidArgumentException::class,
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/ContactPersons/UseCase/MarkEmailAsVerified/CommandTest.php b/tests/Unit/ContactPersons/UseCase/MarkEmailAsVerified/CommandTest.php
new file mode 100644
index 00000000..61c9c2b1
--- /dev/null
+++ b/tests/Unit/ContactPersons/UseCase/MarkEmailAsVerified/CommandTest.php
@@ -0,0 +1,77 @@
+expectException($expectedException);
+ }
+
+ $command = new Command(
+ $uuid,
+ $email,
+ $emailVerifiedAt
+ );
+
+ self::assertEquals($uuid, $command->contactPersonId);
+ self::assertSame($email, $command->email);
+ self::assertEquals($emailVerifiedAt, $command->emailVerifiedAt);
+ }
+
+ public static function commandDataProvider(): array
+ {
+ return [
+ 'valid data' => [
+ Uuid::v7(),
+ 'john.doe@example.com',
+ new CarbonImmutable(),
+ ],
+ 'valid data without date' => [
+ Uuid::v7(),
+ 'john.doe@example.com',
+ null,
+ ],
+ 'invalid email: empty' => [
+ Uuid::v7(),
+ '',
+ null,
+ \InvalidArgumentException::class,
+ ],
+ 'invalid email: spaces' => [
+ Uuid::v7(),
+ ' ',
+ null,
+ \InvalidArgumentException::class,
+ ],
+ 'invalid email: format' => [
+ Uuid::v7(),
+ 'not-an-email',
+ null,
+ \InvalidArgumentException::class,
+ ],
+ ];
+ }
+}