From 66ac18e05ffc3a5996617aefc91629c035daad61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 21:48:48 +0000 Subject: [PATCH 1/4] Implement Bitrix24Partners support (issue #70) This commit implements comprehensive support for Bitrix24Partners following the established DDD/CQRS patterns in the codebase. ## Features Implemented: ### Core Domain - Bitrix24Partner entity with full interface implementation - Repository with all required query methods - Doctrine XML mapping with unique constraint on bitrix24PartnerId - PhoneNumber Doctrine custom type for phone number storage ### Use Cases (CQRS) - Create: Create new partner records - Update: Update partner information - Delete: Soft-delete partners (mark as deleted) - MarkAsBlocked: Block partner accounts - MarkAsActive: Reactivate blocked partners ### CLI Tools - ScrapePartnersCommand: Web scraper for https://www.bitrix24.ru/partners/ - Parses partner data from HTML - Generates CSV output - Configurable URL and output path - ImportPartnersCsvCommand: CSV import utility - Bulk import partners from CSV files - Phone number parsing with libphonenumber - Error handling with --skip-errors option ### Testing - Unit tests for entity (extends SDK contract tests) - Unit tests for Create command validation ## Technical Details: - Follows existing bounded context patterns - Uses value objects where appropriate - Implements domain events for all state changes - Full validation in command constructors - Comprehensive error handling Resolves: #70 --- ...x24Partners.Entity.Bitrix24Partner.dcm.xml | 35 ++ .../Console/ImportPartnersCsvCommand.php | 189 ++++++++++ .../Console/ScrapePartnersCommand.php | 194 ++++++++++ .../Entity/Bitrix24Partner.php | 351 ++++++++++++++++++ .../Doctrine/Bitrix24PartnerRepository.php | 158 ++++++++ .../UseCase/Create/Command.php | 49 +++ .../UseCase/Create/Handler.php | 50 +++ .../UseCase/Delete/Command.php | 15 + .../UseCase/Delete/Handler.php | 35 ++ .../UseCase/MarkAsActive/Command.php | 15 + .../UseCase/MarkAsActive/Handler.php | 35 ++ .../UseCase/MarkAsBlocked/Command.php | 15 + .../UseCase/MarkAsBlocked/Handler.php | 35 ++ .../UseCase/Update/Command.php | 51 +++ .../UseCase/Update/Handler.php | 62 ++++ .../Doctrine/PhoneNumberType.php | 78 ++++ tests/EntityManagerFactory.php | 5 + .../Entity/Bitrix24PartnerTest.php | 51 +++ .../UseCase/Create/CommandTest.php | 129 +++++++ 19 files changed, 1552 insertions(+) create mode 100644 config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml create mode 100644 src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php create mode 100644 src/Bitrix24Partners/Console/ScrapePartnersCommand.php create mode 100644 src/Bitrix24Partners/Entity/Bitrix24Partner.php create mode 100644 src/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepository.php create mode 100644 src/Bitrix24Partners/UseCase/Create/Command.php create mode 100644 src/Bitrix24Partners/UseCase/Create/Handler.php create mode 100644 src/Bitrix24Partners/UseCase/Delete/Command.php create mode 100644 src/Bitrix24Partners/UseCase/Delete/Handler.php create mode 100644 src/Bitrix24Partners/UseCase/MarkAsActive/Command.php create mode 100644 src/Bitrix24Partners/UseCase/MarkAsActive/Handler.php create mode 100644 src/Bitrix24Partners/UseCase/MarkAsBlocked/Command.php create mode 100644 src/Bitrix24Partners/UseCase/MarkAsBlocked/Handler.php create mode 100644 src/Bitrix24Partners/UseCase/Update/Command.php create mode 100644 src/Bitrix24Partners/UseCase/Update/Handler.php create mode 100644 src/Infrastructure/Doctrine/PhoneNumberType.php create mode 100644 tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php create mode 100644 tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php diff --git a/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml b/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml new file mode 100644 index 0000000..194a6a6 --- /dev/null +++ b/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php new file mode 100644 index 0000000..517a2c9 --- /dev/null +++ b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php @@ -0,0 +1,189 @@ +addArgument( + 'file', + InputArgument::REQUIRED, + 'Path to CSV file to import' + ) + ->addOption( + 'skip-errors', + 's', + InputOption::VALUE_NONE, + 'Skip rows with errors and continue processing' + ); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $file = $input->getArgument('file'); + $skipErrors = $input->getOption('skip-errors'); + + if (!file_exists($file)) { + $io->error(sprintf('File not found: %s', $file)); + + return Command::FAILURE; + } + + $io->title('Importing Bitrix24 Partners from CSV'); + $io->info(sprintf('Reading file: %s', $file)); + + try { + $imported = $this->importFromCsv($file, $skipErrors, $io); + + $io->success(sprintf('Successfully imported %d partners', $imported)); + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error(sprintf('Error: %s', $e->getMessage())); + + return Command::FAILURE; + } + } + + private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io): int + { + $fp = fopen($file, 'r'); + if (false === $fp) { + throw new \RuntimeException(sprintf('Cannot open file: %s', $file)); + } + + $phoneUtil = PhoneNumberUtil::getInstance(); + $imported = 0; + $skipped = 0; + $lineNumber = 0; + + // Read header + $header = fgetcsv($fp); + if (false === $header) { + fclose($fp); + throw new \RuntimeException('CSV file is empty'); + } + + $lineNumber++; + + // Validate header + $expectedHeaders = ['title', 'site', 'phone', 'email', 'bitrix24_partner_id', 'open_line_id', 'external_id']; + if ($header !== $expectedHeaders) { + $io->warning(sprintf( + 'CSV header mismatch. Expected: %s, Got: %s', + implode(', ', $expectedHeaders), + implode(', ', $header) + )); + } + + // Process rows + while (false !== ($row = fgetcsv($fp))) { + $lineNumber++; + + try { + // Skip empty rows + if (empty(array_filter($row))) { + continue; + } + + // Parse row data + $title = trim($row[0] ?? ''); + $site = !empty($row[1] ?? '') ? trim($row[1]) : null; + $phoneString = !empty($row[2] ?? '') ? trim($row[2]) : null; + $email = !empty($row[3] ?? '') ? trim($row[3]) : null; + $bitrix24PartnerId = !empty($row[4] ?? '') ? (int) $row[4] : null; + $openLineId = !empty($row[5] ?? '') ? trim($row[5]) : null; + $externalId = !empty($row[6] ?? '') ? trim($row[6]) : null; + + // Validate required fields + if (empty($title)) { + throw new \InvalidArgumentException('Title is required'); + } + + // Parse phone number + $phone = null; + if (null !== $phoneString) { + try { + $phone = $phoneUtil->parse($phoneString, 'RU'); + } catch (NumberParseException $e) { + if (!$skipErrors) { + throw new \InvalidArgumentException( + sprintf('Invalid phone number: %s', $phoneString), + 0, + $e + ); + } + $io->warning(sprintf('Line %d: Invalid phone number "%s", skipping phone', $lineNumber, $phoneString)); + $phone = null; + } + } + + // Create partner + $command = new CreateCommand( + $title, + $site, + $phone, + $email, + $bitrix24PartnerId, + $openLineId, + $externalId + ); + + $this->createHandler->handle($command); + $imported++; + + $io->writeln(sprintf('Imported: %s', $title)); + } catch (\Exception $e) { + if (!$skipErrors) { + fclose($fp); + throw new \RuntimeException( + sprintf('Error on line %d: %s', $lineNumber, $e->getMessage()), + 0, + $e + ); + } + + $skipped++; + $io->warning(sprintf('Line %d: Skipped due to error: %s', $lineNumber, $e->getMessage())); + } + } + + fclose($fp); + + if ($skipped > 0) { + $io->note(sprintf('Skipped %d rows due to errors', $skipped)); + } + + return $imported; + } +} diff --git a/src/Bitrix24Partners/Console/ScrapePartnersCommand.php b/src/Bitrix24Partners/Console/ScrapePartnersCommand.php new file mode 100644 index 0000000..439ed79 --- /dev/null +++ b/src/Bitrix24Partners/Console/ScrapePartnersCommand.php @@ -0,0 +1,194 @@ +addOption( + 'output', + 'o', + InputOption::VALUE_OPTIONAL, + 'Output CSV file path', + 'partners.csv' + ) + ->addOption( + 'url', + 'u', + InputOption::VALUE_OPTIONAL, + 'URL to scrape partners from', + 'https://www.bitrix24.ru/partners/' + ); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $url = $input->getOption('url'); + $outputFile = $input->getOption('output'); + + $io->title('Scraping Bitrix24 Partners'); + $io->info(sprintf('Fetching partners from: %s', $url)); + + try { + // Fetch HTML content + $html = $this->fetchUrl($url); + + // Parse partners from HTML + $partners = $this->parsePartners($html); + + if (empty($partners)) { + $io->warning('No partners found'); + + return Command::FAILURE; + } + + // Save to CSV + $this->saveToCsv($partners, $outputFile); + + $io->success(sprintf('Successfully scraped %d partners and saved to %s', count($partners), $outputFile)); + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error(sprintf('Error: %s', $e->getMessage())); + + return Command::FAILURE; + } + } + + private function fetchUrl(string $url): string + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); + + $html = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if (false === $html || 200 !== $httpCode) { + throw new \RuntimeException(sprintf('Failed to fetch URL: HTTP %d', $httpCode)); + } + + return $html; + } + + /** + * @return array> + */ + private function parsePartners(string $html): array + { + $partners = []; + + // Create DOMDocument to parse HTML + $dom = new \DOMDocument(); + // Suppress warnings from malformed HTML + @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + + $xpath = new \DOMXPath($dom); + + // Try to find partner cards/blocks + // This is a generic pattern - may need adjustment based on actual HTML structure + $partnerNodes = $xpath->query("//*[contains(@class, 'partner')]"); + + if (0 === $partnerNodes->length) { + // Try alternative selectors + $partnerNodes = $xpath->query("//article|//div[contains(@class, 'card')]"); + } + + foreach ($partnerNodes as $node) { + $partner = [ + 'title' => '', + 'site' => '', + 'phone' => '', + 'email' => '', + ]; + + // Extract title + $titleNode = $xpath->query(".//*[contains(@class, 'title')]|.//h1|.//h2|.//h3", $node); + if ($titleNode->length > 0) { + $partner['title'] = trim($titleNode->item(0)->textContent); + } + + // Extract website + $linkNode = $xpath->query(".//a[contains(@href, 'http')]", $node); + if ($linkNode->length > 0) { + $href = $linkNode->item(0)->getAttribute('href'); + if (!empty($href) && str_contains($href, 'http')) { + $partner['site'] = $href; + } + } + + // Extract email + $emailNode = $xpath->query(".//a[contains(@href, 'mailto:')]", $node); + if ($emailNode->length > 0) { + $email = str_replace('mailto:', '', $emailNode->item(0)->getAttribute('href')); + $partner['email'] = $email; + } + + // Extract phone + $phoneNode = $xpath->query(".//a[contains(@href, 'tel:')]", $node); + if ($phoneNode->length > 0) { + $phone = str_replace('tel:', '', $phoneNode->item(0)->getAttribute('href')); + $partner['phone'] = $phone; + } + + // Only add if we have at least a title + if (!empty($partner['title'])) { + $partners[] = $partner; + } + } + + return $partners; + } + + /** + * @param array> $partners + */ + private function saveToCsv(array $partners, string $filename): void + { + $fp = fopen($filename, 'w'); + if (false === $fp) { + throw new \RuntimeException(sprintf('Cannot open file: %s', $filename)); + } + + // Write header + fputcsv($fp, ['title', 'site', 'phone', 'email', 'bitrix24_partner_id', 'open_line_id', 'external_id']); + + // Write data + foreach ($partners as $partner) { + fputcsv($fp, [ + $partner['title'] ?? '', + $partner['site'] ?? '', + $partner['phone'] ?? '', + $partner['email'] ?? '', + '', // bitrix24_partner_id + '', // open_line_id + '', // external_id + ]); + } + + fclose($fp); + } +} diff --git a/src/Bitrix24Partners/Entity/Bitrix24Partner.php b/src/Bitrix24Partners/Entity/Bitrix24Partner.php new file mode 100644 index 0000000..da9c3fb --- /dev/null +++ b/src/Bitrix24Partners/Entity/Bitrix24Partner.php @@ -0,0 +1,351 @@ + + * + * 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\Bitrix24Partners\Entity; + +use Bitrix24\Lib\AggregateRoot; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterface; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerStatus; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerBlockedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerCreatedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerDeletedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerEmailChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerExternalIdChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerIdChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerOpenLineIdChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerPhoneChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerSiteChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerTitleChangedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerUnblockedEvent; +use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; +use Carbon\CarbonImmutable; +use libphonenumber\PhoneNumber; +use Symfony\Component\Uid\Uuid; + +class Bitrix24Partner extends AggregateRoot implements Bitrix24PartnerInterface +{ + private readonly CarbonImmutable $createdAt; + private CarbonImmutable $updatedAt; + private Bitrix24PartnerStatus $status = Bitrix24PartnerStatus::active; + private ?string $comment = null; + + public function __construct( + private readonly Uuid $id, + private string $title, + private ?string $site = null, + private ?PhoneNumber $phone = null, + private ?string $email = null, + private ?int $bitrix24PartnerId = null, + private ?string $openLineId = null, + private ?string $externalId = null, + private bool $isEmitBitrix24PartnerCreatedEvent = false + ) { + $this->createdAt = new CarbonImmutable(); + $this->updatedAt = new CarbonImmutable(); + $this->addPartnerCreatedEventIfNeeded($this->isEmitBitrix24PartnerCreatedEvent); + } + + #[\Override] + public function getId(): Uuid + { + return $this->id; + } + + #[\Override] + public function getCreatedAt(): CarbonImmutable + { + return $this->createdAt; + } + + #[\Override] + public function getUpdatedAt(): CarbonImmutable + { + return $this->updatedAt; + } + + #[\Override] + public function getStatus(): Bitrix24PartnerStatus + { + return $this->status; + } + + #[\Override] + public function getTitle(): string + { + return $this->title; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setTitle(string $title): void + { + if ('' === trim($title)) { + throw new InvalidArgumentException('title cannot be empty'); + } + + $oldTitle = $this->title; + $this->title = $title; + $this->updatedAt = new CarbonImmutable(); + + if ($oldTitle !== $title) { + $this->events[] = new Bitrix24PartnerTitleChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getSite(): ?string + { + return $this->site; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setSite(?string $site): void + { + if (null !== $site && '' === trim($site)) { + throw new InvalidArgumentException('site cannot be empty string'); + } + + $oldSite = $this->site; + $this->site = $site; + $this->updatedAt = new CarbonImmutable(); + + if ($oldSite !== $site) { + $this->events[] = new Bitrix24PartnerSiteChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getPhone(): ?PhoneNumber + { + return $this->phone; + } + + #[\Override] + public function setPhone(?PhoneNumber $phone): void + { + $this->phone = $phone; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new Bitrix24PartnerPhoneChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + + #[\Override] + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setEmail(?string $email): void + { + if (null !== $email && '' === trim($email)) { + throw new InvalidArgumentException('email cannot be empty string'); + } + + $oldEmail = $this->email; + $this->email = $email; + $this->updatedAt = new CarbonImmutable(); + + if ($oldEmail !== $email) { + $this->events[] = new Bitrix24PartnerEmailChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getComment(): ?string + { + return $this->comment; + } + + #[\Override] + public function getBitrix24PartnerId(): ?int + { + return $this->bitrix24PartnerId; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setBitrix24PartnerId(?int $bitrix24PartnerId): void + { + if (null !== $bitrix24PartnerId && $bitrix24PartnerId < 0) { + throw new InvalidArgumentException('bitrix24PartnerId cannot be negative'); + } + + $oldId = $this->bitrix24PartnerId; + $this->bitrix24PartnerId = $bitrix24PartnerId; + $this->updatedAt = new CarbonImmutable(); + + if ($oldId !== $bitrix24PartnerId) { + $this->events[] = new Bitrix24PartnerIdChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getOpenLineId(): ?string + { + return $this->openLineId; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setOpenLineId(?string $openLineId): void + { + if (null !== $openLineId && '' === trim($openLineId)) { + throw new InvalidArgumentException('openLineId cannot be empty string'); + } + + $oldOpenLineId = $this->openLineId; + $this->openLineId = $openLineId; + $this->updatedAt = new CarbonImmutable(); + + if ($oldOpenLineId !== $openLineId) { + $this->events[] = new Bitrix24PartnerOpenLineIdChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + #[\Override] + public function getExternalId(): ?string + { + return $this->externalId; + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function setExternalId(?string $externalId): void + { + if (null !== $externalId && '' === trim($externalId)) { + throw new InvalidArgumentException('externalId cannot be empty string'); + } + + $oldExternalId = $this->externalId; + $this->externalId = $externalId; + $this->updatedAt = new CarbonImmutable(); + + if ($oldExternalId !== $externalId) { + $this->events[] = new Bitrix24PartnerExternalIdChangedEvent( + $this->id, + new CarbonImmutable() + ); + } + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function markAsActive(?string $comment): void + { + if (Bitrix24PartnerStatus::blocked !== $this->status) { + throw new InvalidArgumentException( + sprintf( + 'you can activate partner only in status «blocked», now partner in status «%s»', + $this->status->name + ) + ); + } + + $this->status = Bitrix24PartnerStatus::active; + $this->comment = $comment; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new Bitrix24PartnerUnblockedEvent( + $this->id, + new CarbonImmutable(), + $this->comment + ); + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function markAsBlocked(?string $comment): void + { + if (Bitrix24PartnerStatus::deleted === $this->status) { + throw new InvalidArgumentException('you cannot block partner in status «deleted»'); + } + + $this->status = Bitrix24PartnerStatus::blocked; + $this->comment = $comment; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new Bitrix24PartnerBlockedEvent( + $this->id, + new CarbonImmutable(), + $this->comment + ); + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function markAsDeleted(?string $comment): void + { + if (Bitrix24PartnerStatus::deleted === $this->status) { + throw new InvalidArgumentException('partner already in status «deleted»'); + } + + $this->status = Bitrix24PartnerStatus::deleted; + $this->comment = $comment; + $this->updatedAt = new CarbonImmutable(); + + $this->events[] = new Bitrix24PartnerDeletedEvent( + $this->id, + new CarbonImmutable(), + $this->comment + ); + } + + private function addPartnerCreatedEventIfNeeded(bool $isEmitCreatedEvent): void + { + if ($isEmitCreatedEvent) { + $this->events[] = new Bitrix24PartnerCreatedEvent( + $this->id, + $this->createdAt + ); + } + } +} diff --git a/src/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepository.php b/src/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepository.php new file mode 100644 index 0000000..19634a6 --- /dev/null +++ b/src/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepository.php @@ -0,0 +1,158 @@ +getClassMetadata(Bitrix24Partner::class)); + } + + /** + * @phpstan-return Bitrix24PartnerInterface&AggregateRootEventsEmitterInterface + * + * @throws Bitrix24PartnerNotFoundException + */ + #[\Override] + public function getById(Uuid $uuid): Bitrix24PartnerInterface + { + $partner = $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.id = :id') + ->andWhere('p.status != :status') + ->setParameter('id', $uuid) + ->setParameter('status', Bitrix24PartnerStatus::deleted) + ->getQuery() + ->getOneOrNullResult() + ; + + if (null === $partner) { + throw new Bitrix24PartnerNotFoundException( + sprintf('bitrix24 partner not found by id %s', $uuid->toRfc4122()) + ); + } + + return $partner; + } + + #[\Override] + public function save(Bitrix24PartnerInterface $bitrix24Partner): void + { + $this->getEntityManager()->persist($bitrix24Partner); + } + + /** + * @throws InvalidArgumentException + * @throws Bitrix24PartnerNotFoundException + */ + #[\Override] + public function delete(Uuid $uuid): void + { + $bitrix24Partner = $this->getEntityManager()->getRepository(Bitrix24Partner::class)->find($uuid); + + if (null === $bitrix24Partner) { + throw new Bitrix24PartnerNotFoundException( + sprintf('bitrix24 partner not found by id %s', $uuid->toRfc4122()) + ); + } + + if (Bitrix24PartnerStatus::deleted !== $bitrix24Partner->getStatus()) { + throw new InvalidArgumentException( + sprintf( + 'you cannot delete bitrix24 partner «%s», they must be in status «deleted», current status «%s»', + $bitrix24Partner->getId()->toRfc4122(), + $bitrix24Partner->getStatus()->name + ) + ); + } + + $this->getEntityManager()->remove($bitrix24Partner); + } + + /** + * @throws InvalidArgumentException + */ + #[\Override] + public function findByBitrix24PartnerId(int $bitrix24PartnerId): ?Bitrix24PartnerInterface + { + if ($bitrix24PartnerId < 0) { + throw new InvalidArgumentException('bitrix24PartnerId cannot be negative'); + } + + return $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.bitrix24PartnerId = :partnerId') + ->andWhere('p.status != :status') + ->setParameter('partnerId', $bitrix24PartnerId) + ->setParameter('status', Bitrix24PartnerStatus::deleted) + ->getQuery() + ->getOneOrNullResult() + ; + } + + /** + * @return array + * + * @throws InvalidArgumentException + */ + #[\Override] + public function findByTitle(string $title): array + { + if ('' === trim($title)) { + throw new InvalidArgumentException('title cannot be empty'); + } + + return $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.title LIKE :title') + ->andWhere('p.status != :status') + ->setParameter('title', '%' . $title . '%') + ->setParameter('status', Bitrix24PartnerStatus::deleted) + ->getQuery() + ->getResult() + ; + } + + /** + * @return array + * + * @throws InvalidArgumentException + */ + #[\Override] + public function findByExternalId(string $externalId, ?Bitrix24PartnerStatus $status = null): array + { + if ('' === trim($externalId)) { + throw new InvalidArgumentException('externalId cannot be empty'); + } + + $qb = $this->getEntityManager()->getRepository(Bitrix24Partner::class) + ->createQueryBuilder('p') + ->where('p.externalId = :externalId') + ->setParameter('externalId', $externalId); + + if (null !== $status) { + $qb->andWhere('p.status = :status') + ->setParameter('status', $status); + } else { + $qb->andWhere('p.status != :status') + ->setParameter('status', Bitrix24PartnerStatus::deleted); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/src/Bitrix24Partners/UseCase/Create/Command.php b/src/Bitrix24Partners/UseCase/Create/Command.php new file mode 100644 index 0000000..e2ce881 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Create/Command.php @@ -0,0 +1,49 @@ +validate(); + } + + private function validate(): void + { + if ('' === trim($this->title)) { + throw new \InvalidArgumentException('title must be a non-empty string'); + } + + if (null !== $this->site && '' === trim($this->site)) { + throw new \InvalidArgumentException('site must be null or non-empty string'); + } + + if (null !== $this->email && '' === trim($this->email)) { + throw new \InvalidArgumentException('email must be null or non-empty string'); + } + + if (null !== $this->bitrix24PartnerId && $this->bitrix24PartnerId < 0) { + throw new \InvalidArgumentException('bitrix24PartnerId must be null or non-negative integer'); + } + + if (null !== $this->openLineId && '' === trim($this->openLineId)) { + throw new \InvalidArgumentException('openLineId must be null or non-empty string'); + } + + if (null !== $this->externalId && '' === trim($this->externalId)) { + throw new \InvalidArgumentException('externalId must be null or non-empty string'); + } + } +} diff --git a/src/Bitrix24Partners/UseCase/Create/Handler.php b/src/Bitrix24Partners/UseCase/Create/Handler.php new file mode 100644 index 0000000..a0384f7 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Create/Handler.php @@ -0,0 +1,50 @@ +logger->info('Bitrix24Partners.Create.start', [ + 'title' => $command->title, + 'bitrix24_partner_id' => $command->bitrix24PartnerId, + ]); + + $uuidV7 = Uuid::v7(); + + $bitrix24Partner = new Bitrix24Partner( + $uuidV7, + $command->title, + $command->site, + $command->phone, + $command->email, + $command->bitrix24PartnerId, + $command->openLineId, + $command->externalId, + true + ); + + $this->bitrix24PartnerRepository->save($bitrix24Partner); + $this->flusher->flush($bitrix24Partner); + + $this->logger->info('Bitrix24Partners.Create.finish', [ + 'partner_id' => $uuidV7->toRfc4122(), + 'title' => $command->title, + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/Delete/Command.php b/src/Bitrix24Partners/UseCase/Delete/Command.php new file mode 100644 index 0000000..0a396a4 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Delete/Command.php @@ -0,0 +1,15 @@ +logger->info('Bitrix24Partners.Delete.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + $partner->markAsDeleted($command->comment); + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.Delete.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/MarkAsActive/Command.php b/src/Bitrix24Partners/UseCase/MarkAsActive/Command.php new file mode 100644 index 0000000..d8bc143 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/MarkAsActive/Command.php @@ -0,0 +1,15 @@ +logger->info('Bitrix24Partners.MarkAsActive.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + $partner->markAsActive($command->comment); + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.MarkAsActive.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/MarkAsBlocked/Command.php b/src/Bitrix24Partners/UseCase/MarkAsBlocked/Command.php new file mode 100644 index 0000000..8c74b5a --- /dev/null +++ b/src/Bitrix24Partners/UseCase/MarkAsBlocked/Command.php @@ -0,0 +1,15 @@ +logger->info('Bitrix24Partners.MarkAsBlocked.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + $partner->markAsBlocked($command->comment); + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.MarkAsBlocked.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/src/Bitrix24Partners/UseCase/Update/Command.php b/src/Bitrix24Partners/UseCase/Update/Command.php new file mode 100644 index 0000000..ee4fc72 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Update/Command.php @@ -0,0 +1,51 @@ +validate(); + } + + private function validate(): void + { + if (null !== $this->title && '' === trim($this->title)) { + throw new \InvalidArgumentException('title must be null or non-empty string'); + } + + if (null !== $this->site && '' === trim($this->site)) { + throw new \InvalidArgumentException('site must be null or non-empty string'); + } + + if (null !== $this->email && '' === trim($this->email)) { + throw new \InvalidArgumentException('email must be null or non-empty string'); + } + + if (null !== $this->bitrix24PartnerId && $this->bitrix24PartnerId < 0) { + throw new \InvalidArgumentException('bitrix24PartnerId must be null or non-negative integer'); + } + + if (null !== $this->openLineId && '' === trim($this->openLineId)) { + throw new \InvalidArgumentException('openLineId must be null or non-empty string'); + } + + if (null !== $this->externalId && '' === trim($this->externalId)) { + throw new \InvalidArgumentException('externalId must be null or non-empty string'); + } + } +} diff --git a/src/Bitrix24Partners/UseCase/Update/Handler.php b/src/Bitrix24Partners/UseCase/Update/Handler.php new file mode 100644 index 0000000..a3c9099 --- /dev/null +++ b/src/Bitrix24Partners/UseCase/Update/Handler.php @@ -0,0 +1,62 @@ +logger->info('Bitrix24Partners.Update.start', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + + $partner = $this->bitrix24PartnerRepository->getById($command->id); + + if (null !== $command->title) { + $partner->setTitle($command->title); + } + + if (null !== $command->site) { + $partner->setSite($command->site); + } + + if (null !== $command->phone) { + $partner->setPhone($command->phone); + } + + if (null !== $command->email) { + $partner->setEmail($command->email); + } + + if (null !== $command->bitrix24PartnerId) { + $partner->setBitrix24PartnerId($command->bitrix24PartnerId); + } + + if (null !== $command->openLineId) { + $partner->setOpenLineId($command->openLineId); + } + + if (null !== $command->externalId) { + $partner->setExternalId($command->externalId); + } + + $this->bitrix24PartnerRepository->save($partner); + $this->flusher->flush($partner); + + $this->logger->info('Bitrix24Partners.Update.finish', [ + 'partner_id' => $command->id->toRfc4122(), + ]); + } +} diff --git a/src/Infrastructure/Doctrine/PhoneNumberType.php b/src/Infrastructure/Doctrine/PhoneNumberType.php new file mode 100644 index 0000000..a212ac0 --- /dev/null +++ b/src/Infrastructure/Doctrine/PhoneNumberType.php @@ -0,0 +1,78 @@ +getStringTypeDeclarationSQL($column); + } + + /** + * @param mixed $value + * + * @throws \InvalidArgumentException + */ + #[\Override] + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if (null === $value) { + return null; + } + + if (!$value instanceof PhoneNumber) { + throw new \InvalidArgumentException('Expected \libphonenumber\PhoneNumber, got '.get_debug_type($value)); + } + + $phoneUtil = PhoneNumberUtil::getInstance(); + + return $phoneUtil->format($value, PhoneNumberFormat::E164); + } + + /** + * @param mixed $value + * + * @throws NumberParseException + * @throws \InvalidArgumentException + */ + #[\Override] + public function convertToPHPValue($value, AbstractPlatform $platform): ?PhoneNumber + { + if (null === $value || $value instanceof PhoneNumber) { + return $value; + } + + if (!is_string($value)) { + throw new \InvalidArgumentException('Expected string, got '.get_debug_type($value)); + } + + $phoneUtil = PhoneNumberUtil::getInstance(); + + return $phoneUtil->parse($value, 'ZZ'); + } + + #[\Override] + public function getName(): string + { + return self::NAME; + } + + #[\Override] + public function requiresSQLCommentHint(AbstractPlatform $platform): bool + { + return true; + } +} diff --git a/tests/EntityManagerFactory.php b/tests/EntityManagerFactory.php index e3935cf..ffbdafc 100644 --- a/tests/EntityManagerFactory.php +++ b/tests/EntityManagerFactory.php @@ -4,6 +4,7 @@ namespace Bitrix24\Lib\Tests; +use Bitrix24\Lib\Infrastructure\Doctrine\PhoneNumberType; use Bitrix24\SDK\Core\Exceptions\WrongConfigurationException; use Carbon\Doctrine\CarbonImmutableType; use Doctrine\DBAL\DriverManager; @@ -66,6 +67,10 @@ public static function get(): EntityManagerInterface Type::addType('carbon_immutable', CarbonImmutableType::class); } + if (!Type::hasType(PhoneNumberType::NAME)) { + Type::addType(PhoneNumberType::NAME, PhoneNumberType::class); + } + $configuration = ORMSetup::createXMLMetadataConfiguration($paths, $isDevMode); $connection = DriverManager::getConnection($connectionParams, $configuration); diff --git a/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php b/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php new file mode 100644 index 0000000..09d91df --- /dev/null +++ b/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php @@ -0,0 +1,51 @@ + + * + * 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\Unit\Bitrix24Partners\Entity; + +use Bitrix24\Lib\Bitrix24Partners\Entity\Bitrix24Partner; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterface; +use Bitrix24\SDK\Tests\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterfaceTest; +use libphonenumber\PhoneNumber; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Bitrix24Partner::class)] +class Bitrix24PartnerTest extends Bitrix24PartnerInterfaceTest +{ + #[\Override] + protected function createBitrix24PartnerImplementation( + Uuid $uuid, + string $title, + ?string $site = null, + ?PhoneNumber $phone = null, + ?string $email = null, + ?int $bitrix24PartnerId = null, + ?string $openLineId = null, + ?string $externalId = null + ): Bitrix24PartnerInterface { + return new Bitrix24Partner( + $uuid, + $title, + $site, + $phone, + $email, + $bitrix24PartnerId, + $openLineId, + $externalId + ); + } +} diff --git a/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php b/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php new file mode 100644 index 0000000..7ba2750 --- /dev/null +++ b/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php @@ -0,0 +1,129 @@ +expectException($expectedException); + } + + if (null !== $expectedExceptionMessage) { + $this->expectExceptionMessage($expectedExceptionMessage); + } + + new Command( + $title, + $site, + null, // phone + $email, + $bitrix24PartnerId, + $openLineId, + $externalId + ); + } + + public static function dataForCommand(): \Generator + { + yield 'validCommand' => [ + 'Test Partner', + 'https://example.com', + 'test@example.com', + 123, + 'line-123', + 'ext-123', + null, + null, + ]; + + yield 'emptyTitle' => [ + '', + 'https://example.com', + 'test@example.com', + 123, + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'title must be a non-empty string', + ]; + + yield 'emptySite' => [ + 'Test Partner', + '', + 'test@example.com', + 123, + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'site must be null or non-empty string', + ]; + + yield 'emptyEmail' => [ + 'Test Partner', + 'https://example.com', + '', + 123, + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'email must be null or non-empty string', + ]; + + yield 'negativeBitrix24PartnerId' => [ + 'Test Partner', + 'https://example.com', + 'test@example.com', + -1, + 'line-123', + 'ext-123', + \InvalidArgumentException::class, + 'bitrix24PartnerId must be null or non-negative integer', + ]; + + yield 'emptyOpenLineId' => [ + 'Test Partner', + 'https://example.com', + 'test@example.com', + 123, + '', + 'ext-123', + \InvalidArgumentException::class, + 'openLineId must be null or non-empty string', + ]; + + yield 'emptyExternalId' => [ + 'Test Partner', + 'https://example.com', + 'test@example.com', + 123, + 'line-123', + '', + \InvalidArgumentException::class, + 'externalId must be null or non-empty string', + ]; + } +} From 4bc8ae0164cf9c7a480479a7225a2a81ed4840f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 06:03:03 +0000 Subject: [PATCH 2/4] Replace direct curl calls with Symfony HttpClient - Add symfony/http-client dependency to composer.json - Update ScrapePartnersCommand to use HttpClientInterface via DI - Replace curl_* functions with HttpClient API - Improve testability and follow Symfony best practices Benefits: - Better error handling and exceptions - Easier to mock in tests - Consistent with Symfony ecosystem - Type safety with readonly properties --- composer.json | 1 + .../Console/ScrapePartnersCommand.php | 35 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index ae59392..86eee4d 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "doctrine/doctrine-migrations-bundle": "*", "knplabs/knp-paginator-bundle": "^6", "symfony/event-dispatcher": "^7", + "symfony/http-client": "^7", "symfony/serializer": "^7", "symfony/uid": "^7", "symfony/yaml": "^7", diff --git a/src/Bitrix24Partners/Console/ScrapePartnersCommand.php b/src/Bitrix24Partners/Console/ScrapePartnersCommand.php index 439ed79..27d2f6b 100644 --- a/src/Bitrix24Partners/Console/ScrapePartnersCommand.php +++ b/src/Bitrix24Partners/Console/ScrapePartnersCommand.php @@ -10,6 +10,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\HttpClient\HttpClientInterface; #[AsCommand( name: 'bitrix24:partners:scrape', @@ -17,6 +18,12 @@ )] class ScrapePartnersCommand extends Command { + public function __construct( + private readonly HttpClientInterface $httpClient + ) { + parent::__construct(); + } + #[\Override] protected function configure(): void { @@ -76,22 +83,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function fetchUrl(string $url): string { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); - - $html = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if (false === $html || 200 !== $httpCode) { - throw new \RuntimeException(sprintf('Failed to fetch URL: HTTP %d', $httpCode)); + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ], + 'max_redirects' => 5, + 'verify_peer' => false, + 'verify_host' => false, + ]); + + $statusCode = $response->getStatusCode(); + + if (200 !== $statusCode) { + throw new \RuntimeException(sprintf('Failed to fetch URL: HTTP %d', $statusCode)); } - return $html; + return $response->getContent(); } /** From 53a69243bc693ace79553d1ebcf6092252a6ade2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 15:52:05 +0000 Subject: [PATCH 3/4] Refactor Bitrix24Partner entity and improve robustness This commit implements several improvements based on code review: 1. **Database schema improvements:** - Renamed table from `bitrix24partner` to `bitrix24_partners` - Made `b24_partner_id` required (non-nullable) with unique constraint 2. **Entity improvements:** - Generate UUID internally in constructor (removed UUID parameter) - Made bitrix24PartnerId required (int instead of ?int) - Removed isEmitBitrix24PartnerCreatedEvent parameter, always emit creation event - Fixed setPhone() to only emit event when phone actually changes - Updated getBitrix24PartnerId() return type from ?int to int 3. **CSV parsing fixes:** - Fixed bug where '0' was incorrectly treated as empty - Improved whitespace handling - now properly trims before validation - Added explicit validation for required bitrix24PartnerId field - More robust parsing logic using isset() and explicit empty string checks 4. **Use case updates:** - Updated Create/Command to make bitrix24PartnerId required second parameter - Updated Create/Handler to work with new constructor signature - Improved validation messages 5. **Test updates:** - Updated entity test to work with new constructor (ignores passed UUID) - Updated command test with corrected parameter order and validation messages These changes improve type safety, data integrity, and eliminate edge cases in CSV parsing that could lead to incorrect data import. --- ...x24Partners.Entity.Bitrix24Partner.dcm.xml | 4 +- .../Console/ImportPartnersCsvCommand.php | 28 ++++++++---- .../Entity/Bitrix24Partner.php | 43 ++++++++++--------- .../UseCase/Create/Command.php | 10 ++--- .../UseCase/Create/Handler.php | 11 ++--- .../Entity/Bitrix24PartnerTest.php | 5 ++- .../UseCase/Create/CommandTest.php | 20 ++++----- 7 files changed, 64 insertions(+), 57 deletions(-) diff --git a/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml b/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml index 194a6a6..8bf39f3 100644 --- a/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml +++ b/config/xml/Bitrix24.Lib.Bitrix24Partners.Entity.Bitrix24Partner.dcm.xml @@ -1,7 +1,7 @@ - + @@ -14,7 +14,7 @@ - + diff --git a/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php index 517a2c9..1924a51 100644 --- a/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php +++ b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php @@ -117,19 +117,29 @@ private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io) } // Parse row data - $title = trim($row[0] ?? ''); - $site = !empty($row[1] ?? '') ? trim($row[1]) : null; - $phoneString = !empty($row[2] ?? '') ? trim($row[2]) : null; - $email = !empty($row[3] ?? '') ? trim($row[3]) : null; - $bitrix24PartnerId = !empty($row[4] ?? '') ? (int) $row[4] : null; - $openLineId = !empty($row[5] ?? '') ? trim($row[5]) : null; - $externalId = !empty($row[6] ?? '') ? trim($row[6]) : null; + $title = isset($row[0]) ? trim($row[0]) : ''; + $siteRaw = isset($row[1]) ? trim($row[1]) : ''; + $site = '' !== $siteRaw ? $siteRaw : null; + $phoneStringRaw = isset($row[2]) ? trim($row[2]) : ''; + $phoneString = '' !== $phoneStringRaw ? $phoneStringRaw : null; + $emailRaw = isset($row[3]) ? trim($row[3]) : ''; + $email = '' !== $emailRaw ? $emailRaw : null; + $bitrix24PartnerIdRaw = isset($row[4]) ? trim($row[4]) : ''; + $bitrix24PartnerId = '' !== $bitrix24PartnerIdRaw ? (int) $bitrix24PartnerIdRaw : null; + $openLineIdRaw = isset($row[5]) ? trim($row[5]) : ''; + $openLineId = '' !== $openLineIdRaw ? $openLineIdRaw : null; + $externalIdRaw = isset($row[6]) ? trim($row[6]) : ''; + $externalId = '' !== $externalIdRaw ? $externalIdRaw : null; // Validate required fields - if (empty($title)) { + if ('' === $title) { throw new \InvalidArgumentException('Title is required'); } + if (null === $bitrix24PartnerId) { + throw new \InvalidArgumentException('Bitrix24 Partner ID is required'); + } + // Parse phone number $phone = null; if (null !== $phoneString) { @@ -151,10 +161,10 @@ private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io) // Create partner $command = new CreateCommand( $title, + $bitrix24PartnerId, $site, $phone, $email, - $bitrix24PartnerId, $openLineId, $externalId ); diff --git a/src/Bitrix24Partners/Entity/Bitrix24Partner.php b/src/Bitrix24Partners/Entity/Bitrix24Partner.php index da9c3fb..9ce4e39 100644 --- a/src/Bitrix24Partners/Entity/Bitrix24Partner.php +++ b/src/Bitrix24Partners/Entity/Bitrix24Partner.php @@ -34,25 +34,28 @@ class Bitrix24Partner extends AggregateRoot implements Bitrix24PartnerInterface { + private readonly Uuid $id; private readonly CarbonImmutable $createdAt; private CarbonImmutable $updatedAt; private Bitrix24PartnerStatus $status = Bitrix24PartnerStatus::active; private ?string $comment = null; public function __construct( - private readonly Uuid $id, private string $title, + private int $bitrix24PartnerId, private ?string $site = null, private ?PhoneNumber $phone = null, private ?string $email = null, - private ?int $bitrix24PartnerId = null, private ?string $openLineId = null, - private ?string $externalId = null, - private bool $isEmitBitrix24PartnerCreatedEvent = false + private ?string $externalId = null ) { + $this->id = Uuid::v7(); $this->createdAt = new CarbonImmutable(); $this->updatedAt = new CarbonImmutable(); - $this->addPartnerCreatedEventIfNeeded($this->isEmitBitrix24PartnerCreatedEvent); + $this->events[] = new Bitrix24PartnerCreatedEvent( + $this->id, + $this->createdAt + ); } #[\Override] @@ -144,13 +147,20 @@ public function getPhone(): ?PhoneNumber #[\Override] public function setPhone(?PhoneNumber $phone): void { + $oldPhone = $this->phone; $this->phone = $phone; $this->updatedAt = new CarbonImmutable(); - $this->events[] = new Bitrix24PartnerPhoneChangedEvent( - $this->id, - new CarbonImmutable() - ); + // Compare phone numbers - both null, or both equal + $isChanged = !($oldPhone === null && $phone === null) + && !($oldPhone !== null && $phone !== null && $oldPhone->equals($phone)); + + if ($isChanged) { + $this->events[] = new Bitrix24PartnerPhoneChangedEvent( + $this->id, + new CarbonImmutable() + ); + } } #[\Override] @@ -188,7 +198,7 @@ public function getComment(): ?string } #[\Override] - public function getBitrix24PartnerId(): ?int + public function getBitrix24PartnerId(): int { return $this->bitrix24PartnerId; } @@ -199,8 +209,8 @@ public function getBitrix24PartnerId(): ?int #[\Override] public function setBitrix24PartnerId(?int $bitrix24PartnerId): void { - if (null !== $bitrix24PartnerId && $bitrix24PartnerId < 0) { - throw new InvalidArgumentException('bitrix24PartnerId cannot be negative'); + if (null === $bitrix24PartnerId || $bitrix24PartnerId < 0) { + throw new InvalidArgumentException('bitrix24PartnerId cannot be null or negative'); } $oldId = $this->bitrix24PartnerId; @@ -339,13 +349,4 @@ public function markAsDeleted(?string $comment): void ); } - private function addPartnerCreatedEventIfNeeded(bool $isEmitCreatedEvent): void - { - if ($isEmitCreatedEvent) { - $this->events[] = new Bitrix24PartnerCreatedEvent( - $this->id, - $this->createdAt - ); - } - } } diff --git a/src/Bitrix24Partners/UseCase/Create/Command.php b/src/Bitrix24Partners/UseCase/Create/Command.php index e2ce881..9f7b39d 100644 --- a/src/Bitrix24Partners/UseCase/Create/Command.php +++ b/src/Bitrix24Partners/UseCase/Create/Command.php @@ -10,10 +10,10 @@ { public function __construct( public string $title, + public int $bitrix24PartnerId, public ?string $site = null, public ?PhoneNumber $phone = null, public ?string $email = null, - public ?int $bitrix24PartnerId = null, public ?string $openLineId = null, public ?string $externalId = null ) { @@ -26,6 +26,10 @@ private function validate(): void throw new \InvalidArgumentException('title must be a non-empty string'); } + if ($this->bitrix24PartnerId < 0) { + throw new \InvalidArgumentException('bitrix24PartnerId must be non-negative integer'); + } + if (null !== $this->site && '' === trim($this->site)) { throw new \InvalidArgumentException('site must be null or non-empty string'); } @@ -34,10 +38,6 @@ private function validate(): void throw new \InvalidArgumentException('email must be null or non-empty string'); } - if (null !== $this->bitrix24PartnerId && $this->bitrix24PartnerId < 0) { - throw new \InvalidArgumentException('bitrix24PartnerId must be null or non-negative integer'); - } - if (null !== $this->openLineId && '' === trim($this->openLineId)) { throw new \InvalidArgumentException('openLineId must be null or non-empty string'); } diff --git a/src/Bitrix24Partners/UseCase/Create/Handler.php b/src/Bitrix24Partners/UseCase/Create/Handler.php index a0384f7..337832a 100644 --- a/src/Bitrix24Partners/UseCase/Create/Handler.php +++ b/src/Bitrix24Partners/UseCase/Create/Handler.php @@ -8,7 +8,6 @@ use Bitrix24\Lib\Services\Flusher; use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Repository\Bitrix24PartnerRepositoryInterface; use Psr\Log\LoggerInterface; -use Symfony\Component\Uid\Uuid; readonly class Handler { @@ -25,25 +24,21 @@ public function handle(Command $command): void 'bitrix24_partner_id' => $command->bitrix24PartnerId, ]); - $uuidV7 = Uuid::v7(); - $bitrix24Partner = new Bitrix24Partner( - $uuidV7, $command->title, + $command->bitrix24PartnerId, $command->site, $command->phone, $command->email, - $command->bitrix24PartnerId, $command->openLineId, - $command->externalId, - true + $command->externalId ); $this->bitrix24PartnerRepository->save($bitrix24Partner); $this->flusher->flush($bitrix24Partner); $this->logger->info('Bitrix24Partners.Create.finish', [ - 'partner_id' => $uuidV7->toRfc4122(), + 'partner_id' => $bitrix24Partner->getId()->toRfc4122(), 'title' => $command->title, ]); } diff --git a/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php b/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php index 09d91df..441dcbf 100644 --- a/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php +++ b/tests/Unit/Bitrix24Partners/Entity/Bitrix24PartnerTest.php @@ -37,13 +37,14 @@ protected function createBitrix24PartnerImplementation( ?string $openLineId = null, ?string $externalId = null ): Bitrix24PartnerInterface { + // UUID parameter is ignored as it's generated internally + // bitrix24PartnerId is required in our implementation, use default if null return new Bitrix24Partner( - $uuid, $title, + $bitrix24PartnerId ?? 1, $site, $phone, $email, - $bitrix24PartnerId, $openLineId, $externalId ); diff --git a/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php b/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php index 7ba2750..206a411 100644 --- a/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php +++ b/tests/Unit/Bitrix24Partners/UseCase/Create/CommandTest.php @@ -20,9 +20,9 @@ class CommandTest extends TestCase #[DataProvider('dataForCommand')] public function testValidCommand( string $title, + int $bitrix24PartnerId, ?string $site, ?string $email, - ?int $bitrix24PartnerId, ?string $openLineId, ?string $externalId, ?string $expectedException, @@ -38,10 +38,10 @@ public function testValidCommand( new Command( $title, + $bitrix24PartnerId, $site, null, // phone $email, - $bitrix24PartnerId, $openLineId, $externalId ); @@ -51,9 +51,9 @@ public static function dataForCommand(): \Generator { yield 'validCommand' => [ 'Test Partner', + 123, 'https://example.com', 'test@example.com', - 123, 'line-123', 'ext-123', null, @@ -62,9 +62,9 @@ public static function dataForCommand(): \Generator yield 'emptyTitle' => [ '', + 123, 'https://example.com', 'test@example.com', - 123, 'line-123', 'ext-123', \InvalidArgumentException::class, @@ -73,9 +73,9 @@ public static function dataForCommand(): \Generator yield 'emptySite' => [ 'Test Partner', + 123, '', 'test@example.com', - 123, 'line-123', 'ext-123', \InvalidArgumentException::class, @@ -84,9 +84,9 @@ public static function dataForCommand(): \Generator yield 'emptyEmail' => [ 'Test Partner', + 123, 'https://example.com', '', - 123, 'line-123', 'ext-123', \InvalidArgumentException::class, @@ -95,20 +95,20 @@ public static function dataForCommand(): \Generator yield 'negativeBitrix24PartnerId' => [ 'Test Partner', + -1, 'https://example.com', 'test@example.com', - -1, 'line-123', 'ext-123', \InvalidArgumentException::class, - 'bitrix24PartnerId must be null or non-negative integer', + 'bitrix24PartnerId must be non-negative integer', ]; yield 'emptyOpenLineId' => [ 'Test Partner', + 123, 'https://example.com', 'test@example.com', - 123, '', 'ext-123', \InvalidArgumentException::class, @@ -117,9 +117,9 @@ public static function dataForCommand(): \Generator yield 'emptyExternalId' => [ 'Test Partner', + 123, 'https://example.com', 'test@example.com', - 123, 'line-123', '', \InvalidArgumentException::class, From 3b346efb2ecec0c0bc9c250f1c4b3156a5bbd5bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 19:39:11 +0000 Subject: [PATCH 4/4] Improve CSV handling, deprecate setBitrix24PartnerId, and add tests This commit implements several major improvements and additions: ## 1. Enhanced CSV Import with league/csv - Added `league/csv` dependency for robust CSV parsing - Replaced manual fgetcsv() with league/csv Reader - Implemented progress bar for import operations - Better error handling and iterator management - Named column access instead of index-based ## 2. Progress Bars for Long Operations - Added ProgressBar to ImportPartnersCsvCommand - Shows progress, percentage, time elapsed/estimated, and memory usage - Proper cleanup on error and success ## 3. Immutable bitrix24PartnerId - Removed bitrix24PartnerId from Update\Command - Removed setBitrix24PartnerId() call from Update\Handler - Marked setBitrix24PartnerId() as @deprecated with TODO - Added TODO to create issue in b24phpsdk repository ## 4. Phone Number Type Refactoring - Removed custom src/Infrastructure/Doctrine/PhoneNumberType.php - Added odolbeau/phone-number-bundle dependency - Updated EntityManagerFactory to use bundle's PhoneNumberType - Leverages mature, well-tested phone number handling ## 5. Comprehensive Test Coverage - Added Bitrix24PartnerRepositoryTest extending SDK contract tests - Added Create\HandlerTest for functional testing - Tests follow established patterns from Bitrix24Accounts - Full integration with EntityManager and event dispatching ## Dependencies Added - league/csv: ^9.0 - Professional CSV handling - odolbeau/phone-number-bundle: ^3.0 - Phone number Doctrine integration These changes improve code quality, testability, and user experience with better progress feedback during long-running operations. --- composer.json | 2 + .../Console/ImportPartnersCsvCommand.php | 77 +++++++++-------- .../Entity/Bitrix24Partner.php | 4 + .../UseCase/Update/Command.php | 5 -- .../UseCase/Update/Handler.php | 4 - .../Doctrine/PhoneNumberType.php | 78 ------------------ tests/EntityManagerFactory.php | 2 +- .../Bitrix24PartnerRepositoryTest.php | 76 +++++++++++++++++ .../UseCase/Create/HandlerTest.php | 82 +++++++++++++++++++ 9 files changed, 208 insertions(+), 122 deletions(-) delete mode 100644 src/Infrastructure/Doctrine/PhoneNumberType.php create mode 100644 tests/Functional/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepositoryTest.php create mode 100644 tests/Functional/Bitrix24Partners/UseCase/Create/HandlerTest.php diff --git a/composer.json b/composer.json index 86eee4d..1c7e9b5 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,8 @@ "doctrine/doctrine-bundle": "*", "doctrine/doctrine-migrations-bundle": "*", "knplabs/knp-paginator-bundle": "^6", + "league/csv": "^9.0", + "odolbeau/phone-number-bundle": "^3.0", "symfony/event-dispatcher": "^7", "symfony/http-client": "^7", "symfony/serializer": "^7", diff --git a/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php index 1924a51..32302d3 100644 --- a/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php +++ b/src/Bitrix24Partners/Console/ImportPartnersCsvCommand.php @@ -6,10 +6,13 @@ use Bitrix24\Lib\Bitrix24Partners\UseCase\Create\Command as CreateCommand; use Bitrix24\Lib\Bitrix24Partners\UseCase\Create\Handler as CreateHandler; +use League\Csv\Reader; +use League\Csv\Statement; use libphonenumber\NumberParseException; use libphonenumber\PhoneNumberUtil; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -63,7 +66,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->info(sprintf('Reading file: %s', $file)); try { - $imported = $this->importFromCsv($file, $skipErrors, $io); + $imported = $this->importFromCsv($file, $skipErrors, $io, $output); $io->success(sprintf('Successfully imported %d partners', $imported)); @@ -75,60 +78,69 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io): int + private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io, OutputInterface $output): int { - $fp = fopen($file, 'r'); - if (false === $fp) { - throw new \RuntimeException(sprintf('Cannot open file: %s', $file)); - } + $csv = Reader::createFromPath($file, 'r'); + $csv->setHeaderOffset(0); $phoneUtil = PhoneNumberUtil::getInstance(); $imported = 0; $skipped = 0; - $lineNumber = 0; - - // Read header - $header = fgetcsv($fp); - if (false === $header) { - fclose($fp); - throw new \RuntimeException('CSV file is empty'); - } - - $lineNumber++; // Validate header $expectedHeaders = ['title', 'site', 'phone', 'email', 'bitrix24_partner_id', 'open_line_id', 'external_id']; - if ($header !== $expectedHeaders) { + $actualHeaders = $csv->getHeader(); + if ($actualHeaders !== $expectedHeaders) { $io->warning(sprintf( 'CSV header mismatch. Expected: %s, Got: %s', implode(', ', $expectedHeaders), - implode(', ', $header) + implode(', ', $actualHeaders) )); } - // Process rows - while (false !== ($row = fgetcsv($fp))) { + // Get records + $records = Statement::create()->process($csv); + $totalRecords = iterator_count($records); + + if (0 === $totalRecords) { + $io->warning('No records found in CSV file'); + + return 0; + } + + // Reset iterator + $records = Statement::create()->process($csv); + + // Create progress bar + $progressBar = new ProgressBar($output, $totalRecords); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%'); + $progressBar->start(); + + $lineNumber = 1; // Header is line 1 + + foreach ($records as $record) { $lineNumber++; + $progressBar->advance(); try { // Skip empty rows - if (empty(array_filter($row))) { + if (empty(array_filter($record))) { continue; } // Parse row data - $title = isset($row[0]) ? trim($row[0]) : ''; - $siteRaw = isset($row[1]) ? trim($row[1]) : ''; + $title = isset($record['title']) ? trim($record['title']) : ''; + $siteRaw = isset($record['site']) ? trim($record['site']) : ''; $site = '' !== $siteRaw ? $siteRaw : null; - $phoneStringRaw = isset($row[2]) ? trim($row[2]) : ''; + $phoneStringRaw = isset($record['phone']) ? trim($record['phone']) : ''; $phoneString = '' !== $phoneStringRaw ? $phoneStringRaw : null; - $emailRaw = isset($row[3]) ? trim($row[3]) : ''; + $emailRaw = isset($record['email']) ? trim($record['email']) : ''; $email = '' !== $emailRaw ? $emailRaw : null; - $bitrix24PartnerIdRaw = isset($row[4]) ? trim($row[4]) : ''; + $bitrix24PartnerIdRaw = isset($record['bitrix24_partner_id']) ? trim($record['bitrix24_partner_id']) : ''; $bitrix24PartnerId = '' !== $bitrix24PartnerIdRaw ? (int) $bitrix24PartnerIdRaw : null; - $openLineIdRaw = isset($row[5]) ? trim($row[5]) : ''; + $openLineIdRaw = isset($record['open_line_id']) ? trim($record['open_line_id']) : ''; $openLineId = '' !== $openLineIdRaw ? $openLineIdRaw : null; - $externalIdRaw = isset($row[6]) ? trim($row[6]) : ''; + $externalIdRaw = isset($record['external_id']) ? trim($record['external_id']) : ''; $externalId = '' !== $externalIdRaw ? $externalIdRaw : null; // Validate required fields @@ -153,7 +165,6 @@ private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io) $e ); } - $io->warning(sprintf('Line %d: Invalid phone number "%s", skipping phone', $lineNumber, $phoneString)); $phone = null; } } @@ -171,11 +182,9 @@ private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io) $this->createHandler->handle($command); $imported++; - - $io->writeln(sprintf('Imported: %s', $title)); } catch (\Exception $e) { if (!$skipErrors) { - fclose($fp); + $progressBar->finish(); throw new \RuntimeException( sprintf('Error on line %d: %s', $lineNumber, $e->getMessage()), 0, @@ -184,11 +193,11 @@ private function importFromCsv(string $file, bool $skipErrors, SymfonyStyle $io) } $skipped++; - $io->warning(sprintf('Line %d: Skipped due to error: %s', $lineNumber, $e->getMessage())); } } - fclose($fp); + $progressBar->finish(); + $io->newLine(2); if ($skipped > 0) { $io->note(sprintf('Skipped %d rows due to errors', $skipped)); diff --git a/src/Bitrix24Partners/Entity/Bitrix24Partner.php b/src/Bitrix24Partners/Entity/Bitrix24Partner.php index 9ce4e39..a90eb14 100644 --- a/src/Bitrix24Partners/Entity/Bitrix24Partner.php +++ b/src/Bitrix24Partners/Entity/Bitrix24Partner.php @@ -204,6 +204,10 @@ public function getBitrix24PartnerId(): int } /** + * @deprecated This method is deprecated and should not be used. Bitrix24PartnerId is immutable. + * + * @todo Create issue in https://github.com/bitrix24/b24phpsdk to remove this method from interface + * * @throws InvalidArgumentException */ #[\Override] diff --git a/src/Bitrix24Partners/UseCase/Update/Command.php b/src/Bitrix24Partners/UseCase/Update/Command.php index ee4fc72..626600c 100644 --- a/src/Bitrix24Partners/UseCase/Update/Command.php +++ b/src/Bitrix24Partners/UseCase/Update/Command.php @@ -15,7 +15,6 @@ public function __construct( public ?string $site = null, public ?PhoneNumber $phone = null, public ?string $email = null, - public ?int $bitrix24PartnerId = null, public ?string $openLineId = null, public ?string $externalId = null ) { @@ -36,10 +35,6 @@ private function validate(): void throw new \InvalidArgumentException('email must be null or non-empty string'); } - if (null !== $this->bitrix24PartnerId && $this->bitrix24PartnerId < 0) { - throw new \InvalidArgumentException('bitrix24PartnerId must be null or non-negative integer'); - } - if (null !== $this->openLineId && '' === trim($this->openLineId)) { throw new \InvalidArgumentException('openLineId must be null or non-empty string'); } diff --git a/src/Bitrix24Partners/UseCase/Update/Handler.php b/src/Bitrix24Partners/UseCase/Update/Handler.php index a3c9099..f5d8887 100644 --- a/src/Bitrix24Partners/UseCase/Update/Handler.php +++ b/src/Bitrix24Partners/UseCase/Update/Handler.php @@ -40,10 +40,6 @@ public function handle(Command $command): void $partner->setEmail($command->email); } - if (null !== $command->bitrix24PartnerId) { - $partner->setBitrix24PartnerId($command->bitrix24PartnerId); - } - if (null !== $command->openLineId) { $partner->setOpenLineId($command->openLineId); } diff --git a/src/Infrastructure/Doctrine/PhoneNumberType.php b/src/Infrastructure/Doctrine/PhoneNumberType.php deleted file mode 100644 index a212ac0..0000000 --- a/src/Infrastructure/Doctrine/PhoneNumberType.php +++ /dev/null @@ -1,78 +0,0 @@ -getStringTypeDeclarationSQL($column); - } - - /** - * @param mixed $value - * - * @throws \InvalidArgumentException - */ - #[\Override] - public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string - { - if (null === $value) { - return null; - } - - if (!$value instanceof PhoneNumber) { - throw new \InvalidArgumentException('Expected \libphonenumber\PhoneNumber, got '.get_debug_type($value)); - } - - $phoneUtil = PhoneNumberUtil::getInstance(); - - return $phoneUtil->format($value, PhoneNumberFormat::E164); - } - - /** - * @param mixed $value - * - * @throws NumberParseException - * @throws \InvalidArgumentException - */ - #[\Override] - public function convertToPHPValue($value, AbstractPlatform $platform): ?PhoneNumber - { - if (null === $value || $value instanceof PhoneNumber) { - return $value; - } - - if (!is_string($value)) { - throw new \InvalidArgumentException('Expected string, got '.get_debug_type($value)); - } - - $phoneUtil = PhoneNumberUtil::getInstance(); - - return $phoneUtil->parse($value, 'ZZ'); - } - - #[\Override] - public function getName(): string - { - return self::NAME; - } - - #[\Override] - public function requiresSQLCommentHint(AbstractPlatform $platform): bool - { - return true; - } -} diff --git a/tests/EntityManagerFactory.php b/tests/EntityManagerFactory.php index ffbdafc..02102cb 100644 --- a/tests/EntityManagerFactory.php +++ b/tests/EntityManagerFactory.php @@ -4,7 +4,6 @@ namespace Bitrix24\Lib\Tests; -use Bitrix24\Lib\Infrastructure\Doctrine\PhoneNumberType; use Bitrix24\SDK\Core\Exceptions\WrongConfigurationException; use Carbon\Doctrine\CarbonImmutableType; use Doctrine\DBAL\DriverManager; @@ -15,6 +14,7 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMSetup; +use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; use Symfony\Bridge\Doctrine\Types\UuidType; class EntityManagerFactory diff --git a/tests/Functional/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepositoryTest.php b/tests/Functional/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepositoryTest.php new file mode 100644 index 0000000..1ab633b --- /dev/null +++ b/tests/Functional/Bitrix24Partners/Infrastructure/Doctrine/Bitrix24PartnerRepositoryTest.php @@ -0,0 +1,76 @@ + + * + * 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\Bitrix24Partners\Infrastructure\Doctrine; + +use Bitrix24\Lib\Bitrix24Partners\Entity\Bitrix24Partner; +use Bitrix24\Lib\Bitrix24Partners\Infrastructure\Doctrine\Bitrix24PartnerRepository; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\Lib\Tests\Functional\FlusherDecorator; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Entity\Bitrix24PartnerInterface; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Repository\Bitrix24PartnerRepositoryInterface; +use Bitrix24\SDK\Tests\Application\Contracts\Bitrix24Partners\Repository\Bitrix24PartnerRepositoryInterfaceTest; +use Bitrix24\SDK\Tests\Application\Contracts\TestRepositoryFlusherInterface; +use libphonenumber\PhoneNumber; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +#[CoversClass(Bitrix24PartnerRepository::class)] +class Bitrix24PartnerRepositoryTest extends Bitrix24PartnerRepositoryInterfaceTest +{ + #[\Override] + protected function createBitrix24PartnerImplementation( + Uuid $uuid, + string $title, + ?string $site = null, + ?PhoneNumber $phone = null, + ?string $email = null, + ?int $bitrix24PartnerId = null, + ?string $openLineId = null, + ?string $externalId = null + ): Bitrix24PartnerInterface { + // UUID parameter is ignored as it's generated internally + // bitrix24PartnerId is required in our implementation, use default if null + return new Bitrix24Partner( + $title, + $bitrix24PartnerId ?? 1, + $site, + $phone, + $email, + $openLineId, + $externalId + ); + } + + #[\Override] + protected function createBitrix24PartnerRepositoryImplementation(): Bitrix24PartnerRepositoryInterface + { + $entityManager = EntityManagerFactory::get(); + + return new Bitrix24PartnerRepository($entityManager); + } + + #[\Override] + protected function createRepositoryFlusherImplementation(): TestRepositoryFlusherInterface + { + $entityManager = EntityManagerFactory::get(); + $eventDispatcher = new EventDispatcher(); + + return new FlusherDecorator(new Flusher($entityManager, $eventDispatcher)); + } +} diff --git a/tests/Functional/Bitrix24Partners/UseCase/Create/HandlerTest.php b/tests/Functional/Bitrix24Partners/UseCase/Create/HandlerTest.php new file mode 100644 index 0000000..93b808d --- /dev/null +++ b/tests/Functional/Bitrix24Partners/UseCase/Create/HandlerTest.php @@ -0,0 +1,82 @@ + + * + * 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\Bitrix24Partners\UseCase\Create; + +use Bitrix24\Lib\Bitrix24Partners; +use Bitrix24\Lib\Bitrix24Partners\Infrastructure\Doctrine\Bitrix24PartnerRepository; +use Bitrix24\Lib\Services\Flusher; +use Bitrix24\Lib\Tests\EntityManagerFactory; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Events\Bitrix24PartnerCreatedEvent; +use Bitrix24\SDK\Application\Contracts\Bitrix24Partners\Repository\Bitrix24PartnerRepositoryInterface; +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; + +/** + * @internal + */ +#[CoversClass(Bitrix24Partners\UseCase\Create\Handler::class)] +class HandlerTest extends TestCase +{ + private Bitrix24Partners\UseCase\Create\Handler $handler; + + private Flusher $flusher; + + private Bitrix24PartnerRepositoryInterface $repository; + + private TraceableEventDispatcher $eventDispatcher; + + #[\Override] + protected function setUp(): void + { + $entityManager = EntityManagerFactory::get(); + $this->eventDispatcher = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch()); + $this->repository = new Bitrix24PartnerRepository($entityManager); + $this->flusher = new Flusher($entityManager, $this->eventDispatcher); + $this->handler = new Bitrix24Partners\UseCase\Create\Handler( + $this->repository, + $this->flusher, + new NullLogger() + ); + } + + #[Test] + public function testCreatePartner(): void + { + $this->handler->handle( + new Bitrix24Partners\UseCase\Create\Command( + 'Test Partner', + 12345, + 'https://example.com', + null, + 'test@example.com', + 'line-123', + 'ext-123' + ) + ); + + $this->assertContains( + Bitrix24PartnerCreatedEvent::class, + $this->eventDispatcher->getOrphanedEvents(), + sprintf( + 'not found expected domain event «%s»', + Bitrix24PartnerCreatedEvent::class + ) + ); + } +}