This is a test email sent from phpList Email Service
'); + + if ($syncMode) { + $this->emailService->sendEmailSync($email); + $output->writeln('Test email sent successfully!'); + } else { + $this->emailService->sendEmail($email); + $output->writeln('Test email queued successfully! It will be sent asynchronously.'); + } + + return Command::SUCCESS; + } catch (Exception $e) { + $output->writeln('Failed to send test email: ' . $e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Domain/Messaging/Message/AsyncEmailMessage.php b/src/Domain/Messaging/Message/AsyncEmailMessage.php new file mode 100644 index 00000000..0ea834ba --- /dev/null +++ b/src/Domain/Messaging/Message/AsyncEmailMessage.php @@ -0,0 +1,58 @@ +email = $email; + $this->cc = $cc; + $this->bcc = $bcc; + $this->replyTo = $replyTo; + $this->attachments = $attachments; + } + + public function getEmail(): Email + { + return $this->email; + } + + public function getCc(): array + { + return $this->cc; + } + + public function getBcc(): array + { + return $this->bcc; + } + + public function getReplyTo(): array + { + return $this->replyTo; + } + + public function getAttachments(): array + { + return $this->attachments; + } +} diff --git a/src/Domain/Messaging/Message/SubscriberConfirmationMessage.php b/src/Domain/Messaging/Message/SubscriberConfirmationMessage.php new file mode 100644 index 00000000..676d2e4b --- /dev/null +++ b/src/Domain/Messaging/Message/SubscriberConfirmationMessage.php @@ -0,0 +1,43 @@ +email = $email; + $this->uniqueId = $uniqueId; + $this->htmlEmail = $htmlEmail; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getUniqueId(): string + { + return $this->uniqueId; + } + + public function hasHtmlEmail(): bool + { + return $this->htmlEmail; + } +} diff --git a/src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php b/src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php new file mode 100644 index 00000000..42d91417 --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php @@ -0,0 +1,37 @@ +emailService = $emailService; + } + + /** + * Process an asynchronous email message by sending the email + */ + public function __invoke(AsyncEmailMessage $message): void + { + $this->emailService->sendEmailSync( + $message->getEmail(), + $message->getCc(), + $message->getBcc(), + $message->getReplyTo(), + $message->getAttachments() + ); + } +} diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php new file mode 100644 index 00000000..f81e8365 --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php @@ -0,0 +1,67 @@ +emailService = $emailService; + $this->confirmationUrl = $confirmationUrl; + } + + /** + * Process a subscriber confirmation message by sending the confirmation email + */ + public function __invoke(SubscriberConfirmationMessage $message): void + { + $confirmationLink = $this->generateConfirmationLink($message->getUniqueId()); + + $subject = 'Please confirm your subscription'; + $textContent = "Thank you for subscribing!\n\n" + . "Please confirm your subscription by clicking the link below:\n" + . $confirmationLink . "\n\n" + . 'If you did not request this subscription, please ignore this email.'; + + $htmlContent = ''; + if ($message->hasHtmlEmail()) { + $htmlContent = 'Thank you for subscribing!
' + . 'Please confirm your subscription by clicking the link below:
' + . '' + . 'If you did not request this subscription, please ignore this email.
'; + } + + $email = (new Email()) + ->to($message->getEmail()) + ->subject($subject) + ->text($textContent); + + if (!empty($htmlContent)) { + $email->html($htmlContent); + } + + $this->emailService->sendEmail($email); + } + + /** + * Generate a confirmation link for the subscriber + */ + private function generateConfirmationLink(string $uniqueId): string + { + return $this->confirmationUrl . $uniqueId; + } +} diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index 4031a1ad..46acffeb 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -125,6 +125,12 @@ public function getUuid(): ?string return $this->uuid; } + public function setUuid(string $uuid): self + { + $this->uuid = $uuid; + return $this; + } + public function getOwner(): ?Administrator { return $this->owner; diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index 3469fe1b..ccb05597 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -25,10 +25,10 @@ class UserMessageBounce implements DomainModel, Identity private ?int $id = null; #[ORM\Column(name: 'user', type: 'integer')] - private int $user; + private int $userId; #[ORM\Column(name: 'message', type: 'integer')] - private int $message; + private int $messageId; #[ORM\Column(name: 'bounce', type: 'integer')] private int $bounce; @@ -36,8 +36,9 @@ class UserMessageBounce implements DomainModel, Identity #[ORM\Column(name: 'time', type: 'datetime', options: ['default' => 'CURRENT_TIMESTAMP'])] private DateTime $createdAt; - public function __construct() + public function __construct(int $bounce) { + $this->bounce = $bounce; $this->createdAt = new DateTime(); } @@ -46,14 +47,14 @@ public function getId(): ?int return $this->id; } - public function getUser(): int + public function getUserId(): int { - return $this->user; + return $this->userId; } - public function getMessage(): int + public function getMessageId(): int { - return $this->message; + return $this->messageId; } public function getBounce(): int @@ -66,15 +67,15 @@ public function getCreatedAt(): DateTime return $this->createdAt; } - public function setUser(int $user): self + public function setUserId(int $userId): self { - $this->user = $user; + $this->userId = $userId; return $this; } - public function setMessage(int $message): self + public function setMessageId(int $messageId): self { - $this->message = $message; + $this->messageId = $messageId; return $this; } diff --git a/src/Domain/Messaging/Model/UserMessageForward.php b/src/Domain/Messaging/Model/UserMessageForward.php index 6dbbcc15..9b2a8ef4 100644 --- a/src/Domain/Messaging/Model/UserMessageForward.php +++ b/src/Domain/Messaging/Model/UserMessageForward.php @@ -23,10 +23,10 @@ class UserMessageForward implements DomainModel, Identity private ?int $id = null; #[ORM\Column(name: 'user', type: 'integer')] - private int $user; + private int $userId; #[ORM\Column(name: 'message', type: 'integer')] - private int $message; + private int $messageId; #[ORM\Column(name: 'forward', type: 'string', length: 255, nullable: true)] private ?string $forward = null; @@ -47,14 +47,14 @@ public function getId(): ?int return $this->id; } - public function getUser(): int + public function getUserId(): int { - return $this->user; + return $this->userId; } - public function getMessage(): int + public function getMessageId(): int { - return $this->message; + return $this->messageId; } public function getForward(): ?string @@ -72,15 +72,15 @@ public function getCreatedAt(): DateTime return $this->createdAt; } - public function setUser(int $user): self + public function setUserId(int $userId): self { - $this->user = $user; + $this->userId = $userId; return $this; } - public function setMessage(int $message): self + public function setMessageId(int $messageId): self { - $this->message = $message; + $this->messageId = $messageId; return $this; } diff --git a/src/Domain/Messaging/Repository/ListMessageRepository.php b/src/Domain/Messaging/Repository/ListMessageRepository.php index 555f5fe6..bdfd4f83 100644 --- a/src/Domain/Messaging/Repository/ListMessageRepository.php +++ b/src/Domain/Messaging/Repository/ListMessageRepository.php @@ -11,4 +11,15 @@ class ListMessageRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** @return int[] */ + public function getListIdsByMessageId(int $messageId): array + { + return $this->createQueryBuilder('lm') + ->select('IDENTITY(lm.list)') + ->where('lm.messageId = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->getSingleColumnResult(); + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 674db8a0..1cc65df6 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -12,6 +12,18 @@ class MessageRepository extends AbstractRepository implements PaginatableRepositoryInterface { + /** + * @return Message[] + */ + public function findCampaignsWithoutUuid(): array + { + return $this->createQueryBuilder('m') + ->where('m.uuid IS NULL OR m.uuid = :emptyString') + ->setParameter('emptyString', '') + ->getQuery() + ->getResult(); + } + public function getByOwnerId(int $ownerId): array { return $this->createQueryBuilder('m') diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index 5beb9b89..16f07f79 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -16,7 +16,7 @@ public function getCountByMessageId(int $messageId): int { return (int) $this->createQueryBuilder('umb') ->select('COUNT(umb.id)') - ->where('IDENTITY(umb.message) = :messageId') + ->where('umb.messageId = :messageId') ->setParameter('messageId', $messageId) ->getQuery() ->getSingleScalarResult(); diff --git a/src/Domain/Messaging/Repository/UserMessageForwardRepository.php b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php index b0fa5e58..e2a48b46 100644 --- a/src/Domain/Messaging/Repository/UserMessageForwardRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php @@ -16,7 +16,7 @@ public function getCountByMessageId(int $messageId): int { return (int) $this->createQueryBuilder('umf') ->select('COUNT(umf.id)') - ->where('IDENTITY(umf.message) = :messageId') + ->where('umf.messageId = :messageId') ->setParameter('messageId', $messageId) ->getQuery() ->getSingleScalarResult(); diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php new file mode 100644 index 00000000..86b17ec5 --- /dev/null +++ b/src/Domain/Messaging/Service/EmailService.php @@ -0,0 +1,125 @@ +mailer = $mailer; + $this->defaultFromEmail = $defaultFromEmail; + $this->messageBus = $messageBus; + } + + public function sendEmail( + Email $email, + array $cc = [], + array $bcc = [], + array $replyTo = [], + array $attachments = [] + ): void { + if (count($email->getFrom()) === 0) { + $email->from($this->defaultFromEmail); + } + + $message = new AsyncEmailMessage($email, $cc, $bcc, $replyTo, $attachments); + $this->messageBus->dispatch($message); + } + + public function sendEmailSync( + Email $email, + array $cc = [], + array $bcc = [], + array $replyTo = [], + array $attachments = [] + ): void { + if (count($email->getFrom()) === 0) { + $email->from($this->defaultFromEmail); + } + + foreach ($cc as $ccAddress) { + $email->addCc($ccAddress); + } + + foreach ($bcc as $bccAddress) { + $email->addBcc($bccAddress); + } + + foreach ($replyTo as $replyToAddress) { + $email->addReplyTo($replyToAddress); + } + + foreach ($attachments as $attachment) { + $email->attachFromPath($attachment); + } + + $this->mailer->send($email); + } + + public function sendBulkEmail( + array $toAddresses, + string $subject, + string $text, + string $html = '', + ?string $from = null, + ?string $fromName = null, + array $attachments = [] + ): void { + $baseEmail = (new Email()) + ->subject($subject) + ->text($text) + ->html($html); + + if ($from) { + $baseEmail->from($fromName ? new Address($from, $fromName) : $from); + } + + foreach ($toAddresses as $recipient) { + $email = clone $baseEmail; + $email->to($recipient); + + $this->sendEmail($email, [], [], [], $attachments); + } + } + + public function sendBulkEmailSync( + array $toAddresses, + string $subject, + string $text, + string $html = '', + ?string $from = null, + ?string $fromName = null, + array $attachments = [] + ): void { + $baseEmail = (new Email()) + ->subject($subject) + ->text($text) + ->html($html); + + if ($from) { + $baseEmail->from($fromName ? new Address($from, $fromName) : $from); + } + + foreach ($toAddresses as $recipient) { + $email = clone $baseEmail; + $email->to($recipient); + + $this->sendEmailSync($email, [], [], [], $attachments); + } + } +} diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php new file mode 100644 index 00000000..c602f7d4 --- /dev/null +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -0,0 +1,106 @@ +entityManager = $entityManager; + $this->subscriberRepository = $subscriberRepository; + $this->messageRepository = $messageRepository; + $this->linkTrackService = $linkTrackService; + } + + public function ensureSubscribersHaveUuid(OutputInterface $output): void + { + $subscribersWithoutUuid = $this->subscriberRepository->findSubscribersWithoutUuid(); + + $numSubscribers = count($subscribersWithoutUuid); + if ($numSubscribers > 0) { + $output->writeln(sprintf('Giving a UUID to %d subscribers, this may take a while', $numSubscribers)); + foreach ($subscribersWithoutUuid as $subscriber) { + $subscriber->setUniqueId(bin2hex(random_bytes(16))); + } + $this->entityManager->flush(); + } + } + + public function ensureCampaignsHaveUuid(OutputInterface $output): void + { + $campaignsWithoutUuid = $this->messageRepository->findCampaignsWithoutUuid(); + + $numCampaigns = count($campaignsWithoutUuid); + if ($numCampaigns > 0) { + $output->writeln(sprintf('Giving a UUID to %d campaigns', $numCampaigns)); + foreach ($campaignsWithoutUuid as $campaign) { + $campaign->setUuid(bin2hex(random_bytes(18))); + } + $this->entityManager->flush(); + } + } + + /** + * Process message content to extract URLs and replace them with link track URLs + */ + public function processMessageLinks(Message $message, int $userId): Message + { + if (!$this->linkTrackService->isExtractAndSaveLinksApplicable()) { + return $message; + } + + $savedLinks = $this->linkTrackService->extractAndSaveLinks($message, $userId); + + if (empty($savedLinks)) { + return $message; + } + + $content = $message->getContent(); + $htmlText = $content->getText(); + $footer = $content->getFooter(); + + if ($htmlText !== null) { + $htmlText = $this->replaceLinks($savedLinks, $htmlText); + $content->setText($htmlText); + } + + if ($footer !== null) { + $footer = $this->replaceLinks($savedLinks, $footer); + $content->setFooter($footer); + } + + return $message; + } + + private function replaceLinks(array $savedLinks, string $htmlText): string + { + foreach ($savedLinks as $linkTrack) { + $originalUrl = $linkTrack->getUrl(); + $trackUrl = '/' . self::LINT_TRACK_ENDPOINT . '?id=' . $linkTrack->getId(); + $htmlText = str_replace('href="' . $originalUrl . '"', 'href="' . $trackUrl . '"', $htmlText); + } + + return $htmlText; + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 8da29cf8..ade837f6 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -19,6 +19,18 @@ */ class SubscriberRepository extends AbstractRepository implements PaginatableRepositoryInterface { + /** + * @return Subscriber[] + */ + public function findSubscribersWithoutUuid(): array + { + return $this->createQueryBuilder('s') + ->where('s.uniqueId IS NULL OR s.uniqueId = :emptyString') + ->setParameter('emptyString', '') + ->getQuery() + ->getResult(); + } + public function findOneByEmail(string $email): ?Subscriber { return $this->findOneBy(['email' => $email]); diff --git a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php index 8e2a0307..d8983e65 100644 --- a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php +++ b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php @@ -8,14 +8,19 @@ use PhpList\Core\Domain\Subscription\Model\Dto\AttributeDefinitionDto; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; class AttributeDefinitionManager { private SubscriberAttributeDefinitionRepository $definitionRepository; + private AttributeTypeValidator $attributeTypeValidator; - public function __construct(SubscriberAttributeDefinitionRepository $definitionRepository) - { + public function __construct( + SubscriberAttributeDefinitionRepository $definitionRepository, + AttributeTypeValidator $attributeTypeValidator + ) { $this->definitionRepository = $definitionRepository; + $this->attributeTypeValidator = $attributeTypeValidator; } public function create(AttributeDefinitionDto $attributeDefinitionDto): SubscriberAttributeDefinition @@ -24,6 +29,7 @@ public function create(AttributeDefinitionDto $attributeDefinitionDto): Subscrib if ($existingAttribute) { throw new AttributeDefinitionCreationException('Attribute definition already exists', 409); } + $this->attributeTypeValidator->validate($attributeDefinitionDto->type); $attributeDefinition = (new SubscriberAttributeDefinition()) ->setName($attributeDefinitionDto->name) @@ -46,6 +52,7 @@ public function update( if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) { throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409); } + $this->attributeTypeValidator->validate($attributeDefinitionDto->type); $attributeDefinition ->setName($attributeDefinitionDto->name) diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 92273278..93420795 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -5,22 +5,33 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\MessageBusInterface; class SubscriberManager { private SubscriberRepository $subscriberRepository; private EntityManagerInterface $entityManager; - - public function __construct(SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager) - { + private MessageBusInterface $messageBus; + private SubscriberDeletionService $subscriberDeletionService; + + public function __construct( + SubscriberRepository $subscriberRepository, + EntityManagerInterface $entityManager, + MessageBusInterface $messageBus, + SubscriberDeletionService $subscriberDeletionService + ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; + $this->messageBus = $messageBus; + $this->subscriberDeletionService = $subscriberDeletionService; } public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber @@ -35,9 +46,24 @@ public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber $this->subscriberRepository->save($subscriber); + if ($subscriberDto->requestConfirmation) { + $this->sendConfirmationEmail($subscriber); + } + return $subscriber; } + private function sendConfirmationEmail(Subscriber $subscriber): void + { + $message = new SubscriberConfirmationMessage( + email: $subscriber->getEmail(), + uniqueId:$subscriber->getUniqueId(), + htmlEmail: $subscriber->hasHtmlEmail() + ); + + $this->messageBus->dispatch($message); + } + public function getSubscriber(int $subscriberId): Subscriber { $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); @@ -68,7 +94,7 @@ public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber public function deleteSubscriber(Subscriber $subscriber): void { - $this->subscriberRepository->remove($subscriber); + $this->subscriberDeletionService->deleteLeavingBlacklist($subscriber); } public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber diff --git a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php new file mode 100644 index 00000000..0fdd2f1c --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php @@ -0,0 +1,45 @@ +listMessageRepository = $listMessageRepository; + $this->subscriberRepository = $subscriberRepository; + } + + /** + * Get subscribers for a message + * + * @param Message $message The message to get subscribers for + * @return Subscriber[] Array of subscribers + */ + public function getSubscribersForMessage(Message $message): array + { + $listIds = $this->listMessageRepository->getListIdsByMessageId($message->getId()); + + $subscribers = []; + foreach ($listIds as $listId) { + $listSubscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($listId); + foreach ($listSubscribers as $subscriber) { + $subscribers[$subscriber->getId()] = $subscriber; + } + } + + return array_values($subscribers); + } +} diff --git a/src/Domain/Subscription/Service/SubscriberCsvExporter.php b/src/Domain/Subscription/Service/SubscriberCsvExporter.php index 33aebb38..687e6f22 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvExporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvExporter.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -22,15 +23,18 @@ class SubscriberCsvExporter private SubscriberAttributeManager $attributeManager; private SubscriberRepository $subscriberRepository; private SubscriberAttributeDefinitionRepository $definitionRepository; + private LoggerInterface $logger; public function __construct( SubscriberAttributeManager $attributeManager, SubscriberRepository $subscriberRepository, - SubscriberAttributeDefinitionRepository $definitionRepository + SubscriberAttributeDefinitionRepository $definitionRepository, + LoggerInterface $logger ) { $this->attributeManager = $attributeManager; $this->subscriberRepository = $subscriberRepository; $this->definitionRepository = $definitionRepository; + $this->logger = $logger; } /** @@ -42,16 +46,30 @@ public function __construct( */ public function exportToCsv(?SubscriberFilter $filter = null, int $batchSize = 1000): Response { + $this->logger->info('Starting subscriber CSV export', [ + 'batch_size' => $batchSize, + 'filter' => $filter ? get_class($filter) : 'null' + ]); + if ($filter === null) { $filter = new SubscriberFilter(); + $this->logger->debug('No filter provided, using default filter'); } $tempFilePath = tempnam(sys_get_temp_dir(), 'subscribers_export_'); + $this->logger->debug('Created temporary file for export', ['path' => $tempFilePath]); + $this->generateCsvContent($filter, $batchSize, $tempFilePath, $filter->getColumns()); $response = new BinaryFileResponse($tempFilePath); + $response = $this->configureResponse($response); + + $this->logger->info('Subscriber CSV export completed', [ + 'file_size' => filesize($tempFilePath), + 'temp_file' => $tempFilePath + ]); - return $this->configureResponse($response); + return $response; } /** @@ -121,22 +139,55 @@ private function exportSubscribers( array $headers ): void { $lastId = 0; + $totalExported = 0; + $batchNumber = 0; + + $this->logger->debug('Starting batch export of subscribers', [ + 'batch_size' => $batchSize, + 'attribute_definitions_count' => count($attributeDefinitions), + 'headers_count' => count($headers) + ]); do { + $batchNumber++; + $this->logger->debug('Processing subscriber batch', [ + 'batch_number' => $batchNumber, + 'last_id' => $lastId, + 'batch_size' => $batchSize + ]); + $subscribers = $this->subscriberRepository->getFilteredAfterId( lastId: $lastId, limit: $batchSize, filter: $filter ); + $subscriberCount = count($subscribers); + $this->logger->debug('Retrieved subscribers for batch', [ + 'batch_number' => $batchNumber, + 'count' => $subscriberCount + ]); + foreach ($subscribers as $subscriber) { $row = $this->getSubscriberRow($subscriber, $attributeDefinitions, $headers); fputcsv($handle, $row); $lastId = $subscriber->getId(); } - $subscriberCount = count($subscribers); + $totalExported += $subscriberCount; + + $this->logger->debug('Completed batch processing', [ + 'batch_number' => $batchNumber, + 'processed_in_batch' => $subscriberCount, + 'total_exported' => $totalExported, + 'last_id' => $lastId + ]); } while ($subscriberCount === $batchSize); + + $this->logger->info('Completed exporting all subscribers', [ + 'total_batches' => $batchNumber, + 'total_subscribers' => $totalExported + ]); } /** diff --git a/src/Domain/Subscription/Service/SubscriberDeletionService.php b/src/Domain/Subscription/Service/SubscriberDeletionService.php new file mode 100644 index 00000000..9681a49d --- /dev/null +++ b/src/Domain/Subscription/Service/SubscriberDeletionService.php @@ -0,0 +1,77 @@ +linkTrackUmlClickRepo = $linkTrackUmlClickRepo; + $this->entityManager = $entityManager; + $this->userMessageRepo = $userMessageRepo; + $this->subscriberAttrValueRepo = $subscriberAttrValueRepo; + $this->subscriberHistoryRepo = $subscriberHistoryRepo; + $this->userMessageBounceRepo = $userMessageBounceRepo; + $this->userMessageForwardRepo = $userMessageForwardRepo; + $this->userMessageViewRepo = $userMessageViewRepo; + $this->subscriptionRepo = $subscriptionRepo; + } + + public function deleteLeavingBlacklist(Subscriber $subscriber): void + { + $this->removeEntities($this->linkTrackUmlClickRepo->findBy(['userId' => $subscriber->getId()])); + $this->removeEntities($this->subscriptionRepo->findBy(['subscriber' => $subscriber])); + $this->removeEntities($this->userMessageRepo->findBy(['user' => $subscriber])); + $this->removeEntities($this->subscriberAttrValueRepo->findBy(['subscriber' => $subscriber])); + $this->removeEntities($this->subscriberHistoryRepo->findBy(['subscriber' => $subscriber])); + $this->removeEntities($this->userMessageBounceRepo->findBy(['userId' => $subscriber->getId()])); + $this->removeEntities($this->userMessageForwardRepo->findBy(['userId' => $subscriber->getId()])); + $this->removeEntities($this->userMessageViewRepo->findBy(['userId' => $subscriber->getId()])); + + $this->entityManager->remove($subscriber); + } + + /** + * Remove a collection of entities + * + * @param array $entities + */ + private function removeEntities(array $entities): void + { + foreach ($entities as $entity) { + $this->entityManager->remove($entity); + } + } +} diff --git a/src/Domain/Subscription/Validator/AttributeTypeValidator.php b/src/Domain/Subscription/Validator/AttributeTypeValidator.php new file mode 100644 index 00000000..3923cdfc --- /dev/null +++ b/src/Domain/Subscription/Validator/AttributeTypeValidator.php @@ -0,0 +1,44 @@ +loadSchema(); + + $this->subscriberDeletionService = self::getContainer()->get(SubscriberDeletionService::class); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropDatabase(); + parent::tearDown(); + } + + public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): void + { + $admin = new Administrator(); + $this->entityManager->persist($admin); + + $msg = new Message( + format: new MessageFormat(true, MessageFormat::FORMAT_TEXT), + schedule: new MessageSchedule(1, null, 3, null, null), + metadata: new MessageMetadata('done'), + content: new MessageContent('Owned by Admin 1!'), + options: new MessageOptions(), + owner: $admin + ); + $this->entityManager->persist($msg); + + $subscriber = new Subscriber(); + $subscriber->setEmail('test-delete@example.com'); + $subscriber->setConfirmed(true); + $subscriber->setHtmlEmail(true); + $subscriber->setBlacklisted(false); + $subscriber->setDisabled(false); + $this->entityManager->persist($subscriber); + $this->entityManager->flush(); + + $subscriberId = $subscriber->getId(); + $this->assertNotNull($subscriberId, 'Subscriber ID should not be null'); + + $subscriberList = new SubscriberList(); + $subscriberList->setDescription('Test List Description'); + $this->entityManager->persist($subscriberList); + + $subscription = new Subscription(); + $subscription->setSubscriber($subscriber); + $subscription->setSubscriberList($subscriberList); + $this->entityManager->persist($subscription); + + $linkTrackUmlClick = new LinkTrackUmlClick(); + $linkTrackUmlClick->setMessageId(1); + $linkTrackUmlClick->setUserId($subscriberId); + $this->entityManager->persist($linkTrackUmlClick); + + $userMessage = new UserMessage($subscriber, $msg); + $userMessage->setStatus('sent'); + $this->entityManager->persist($userMessage); + + $userMessageBounce = new UserMessageBounce(1); + $userMessageBounce->setUserId($subscriberId); + $userMessageBounce->setMessageId(1); + $this->entityManager->persist($userMessageBounce); + + $userMessageForward = new UserMessageForward(); + $userMessageForward->setUserId($subscriberId); + $userMessageForward->setMessageId(1); + $this->entityManager->persist($userMessageForward); + + $userMessageView = new UserMessageView(); + $userMessageView->setMessageId(1); + $userMessageView->setUserid($subscriberId); + $this->entityManager->persist($userMessageView); + + $this->entityManager->flush(); + + try { + $this->subscriberDeletionService->deleteLeavingBlacklist($subscriber); + $this->entityManager->flush(); + $this->assertTrue(true, 'No exception was thrown'); + } catch (Exception $e) { + $this->fail('Exception was thrown: ' . $e->getMessage()); + } + + $deletedSubscriber = $this->entityManager->getRepository(Subscriber::class)->find($subscriberId); + $this->assertNull($deletedSubscriber, 'Subscriber should be deleted'); + + $subscriptionRepo = $this->entityManager->getRepository(Subscription::class); + $subscriptions = $subscriptionRepo->findBy(['subscriber' => $subscriber]); + $this->assertEmpty($subscriptions, 'Subscriptions should be deleted'); + + $linkTrackRepo = $this->entityManager->getRepository(LinkTrackUmlClick::class); + $linkTrackUmlClicks = $linkTrackRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($linkTrackUmlClicks, 'LinkTrackUmlClicks should be deleted'); + + $userMessageRepo = $this->entityManager->getRepository(UserMessage::class); + $userMessages = $userMessageRepo->findBy(['user' => $subscriber]); + $this->assertEmpty($userMessages, 'UserMessages should be deleted'); + + $bounceRepo = $this->entityManager->getRepository(UserMessageBounce::class); + $userMessageBounces = $bounceRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($userMessageBounces, 'UserMessageBounces should be deleted'); + + $forwardRepo = $this->entityManager->getRepository(UserMessageForward::class); + $userMessageForwards = $forwardRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($userMessageForwards, 'UserMessageForwards should be deleted'); + + $viewRepo = $this->entityManager->getRepository(UserMessageView::class); + $userMessageViews = $viewRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($userMessageViews, 'UserMessageViews should be deleted'); + } +} diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php new file mode 100644 index 00000000..613e2c1f --- /dev/null +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -0,0 +1,229 @@ +linkTrackRepository = $this->createMock(LinkTrackRepository::class); + $configProvider = $this->createMock(ConfigProvider::class); + + $configProvider->method('get') + ->with('click_track', false) + ->willReturn(true); + + $this->subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + } + + public function testExtractAndSaveLinksWithNoLinks(): void + { + $messageId = 123; + $userId = 456; + + $messageContent = new MessageContent('Test Subject', 'No links here'); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::never())->method('save'); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertEmpty($result); + } + + public function testExtractAndSaveLinksWithLinks(): void + { + $messageId = 123; + $userId = 456; + $htmlContent = 'Check out this link and ' + . 'this one.
'; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::exactly(2)) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertContains($linkTrack->getUrl(), ['https://example.com', 'https://test.com']); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(2, $result); + self::assertSame('https://example.com', $result[0]->getUrl()); + self::assertSame('https://test.com', $result[1]->getUrl()); + } + + public function testExtractAndSaveLinksWithFooter(): void + { + $messageId = 123; + $userId = 456; + $htmlContent = 'Main content with a link.
'; + $footerContent = 'Footer with another link.
'; + + $messageContent = new MessageContent('Test Subject', $htmlContent, null, $footerContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::exactly(2)) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertContains($linkTrack->getUrl(), ['https://example.com', 'https://footer.com']); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(2, $result); + self::assertSame('https://example.com', $result[0]->getUrl()); + self::assertSame('https://footer.com', $result[1]->getUrl()); + } + + public function testExtractAndSaveLinksWithDuplicateLinks(): void + { + $messageId = 123; + $userId = 456; + $htmlContent = ''; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::once()) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertSame('https://example.com', $linkTrack->getUrl()); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(1, $result); + self::assertSame('https://example.com', $result[0]->getUrl()); + } + + public function testExtractAndSaveLinksWithNullText(): void + { + $messageId = 123; + $userId = 456; + $footerContent = 'Footer with a link.
'; + + $messageContent = new MessageContent('Test Subject', null, null, $footerContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::once()) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertSame('https://footer.com', $linkTrack->getUrl()); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(1, $result); + self::assertSame('https://footer.com', $result[0]->getUrl()); + } + + public function testExtractAndSaveLinksWithMessageWithoutId(): void + { + $userId = 456; + $htmlContent = ''; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(null); + $message->method('getContent')->willReturn($messageContent); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Message must have an ID'); + + $this->subject->extractAndSaveLinks($message, $userId); + } + + public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsTrue(): void + { + self::assertTrue($this->subject->isExtractAndSaveLinksApplicable()); + } + + public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsFalse(): void + { + $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('get') + ->with('click_track', false) + ->willReturn(false); + + $subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + + self::assertFalse($subject->isExtractAndSaveLinksApplicable()); + } + + public function testExtractAndSaveLinksWithExistingLink(): void + { + $messageId = 123; + $userId = 456; + $url = 'https://example.com'; + $htmlContent = 'Check out this link.
'; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $existingLinkTrack = new LinkTrack(); + $existingLinkTrack->setMessageId($messageId); + $existingLinkTrack->setUserId($userId); + $existingLinkTrack->setUrl($url); + + $this->linkTrackRepository->expects(self::once()) + ->method('findByUrlUserIdAndMessageId') + ->with($url, $userId, $messageId) + ->willReturn($existingLinkTrack); + + $this->linkTrackRepository->expects(self::never()) + ->method('save'); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(1, $result); + self::assertSame($existingLinkTrack, $result[0]); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php new file mode 100644 index 00000000..3b14122b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php @@ -0,0 +1,146 @@ +createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + + $configRepository->expects($this->once()) + ->method('findOneBy') + ->with(['item' => 'test_item']) + ->willReturn($config); + + $result = $manager->getByItem('test_item'); + + $this->assertSame($config, $result); + $this->assertSame('test_item', $result->getKey()); + $this->assertSame('test_value', $result->getValue()); + } + + public function testGetAllReturnsAllConfigsFromRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config1 = new Config(); + $config1->setKey('item1'); + $config1->setValue('value1'); + + $config2 = new Config(); + $config2->setKey('item2'); + $config2->setValue('value2'); + + $configs = [$config1, $config2]; + + $configRepository->expects($this->once()) + ->method('findAll') + ->willReturn($configs); + + $result = $manager->getAll(); + + $this->assertSame($configs, $result); + $this->assertCount(2, $result); + $this->assertSame('item1', $result[0]->getKey()); + $this->assertSame('value1', $result[0]->getValue()); + $this->assertSame('item2', $result[1]->getKey()); + $this->assertSame('value2', $result[1]->getValue()); + } + + public function testUpdateSavesConfigToRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + $config->setEditable(true); + + $configRepository->expects($this->once()) + ->method('save') + ->with($config); + + $manager->update($config, 'new_value'); + } + + public function testCreateSavesNewConfigToRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $configRepository->expects($this->once()) + ->method('save') + ->with($this->callback(function (Config $config) { + return $config->getKey() === 'test_key' && + $config->getValue() === 'test_value' && + $config->isEditable() === true && + $config->getType() === 'test_type'; + })); + + $manager->create('test_key', 'test_value', true, 'test_type'); + } + public function testGetByItemReturnsNullWhenItemDoesNotExist(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $configRepository->expects($this->once()) + ->method('findOneBy') + ->with(['item' => 'non_existent_item']) + ->willReturn(null); + + $result = $manager->getByItem('non_existent_item'); + + $this->assertNull($result); + } + + public function testUpdateThrowsExceptionWhenConfigIsNotEditable(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + $config->setEditable(false); + + $configRepository->expects($this->never()) + ->method('save'); + + $this->expectException(\PhpList\Core\Domain\Configuration\Exception\ConfigNotEditableException::class); + $this->expectExceptionMessage('Configuration item "test_item" is not editable.'); + + $manager->update($config, 'new_value'); + } + + public function testDeleteRemovesConfigFromRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + + $configRepository->expects($this->once()) + ->method('remove') + ->with($config); + + $manager->delete($config); + } +} diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTest.php index 10508b26..f721f58b 100644 --- a/tests/Unit/Domain/Identity/Model/AdministratorTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTest.php @@ -4,8 +4,11 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Model; +use DateTime; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\Privileges; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use PHPUnit\Framework\TestCase; @@ -75,7 +78,7 @@ public function testUpdateModificationDateSetsModificationDateToNow(): void { $this->subject->updateUpdatedAt(); - self::assertSimilarDates(new \DateTime(), $this->subject->getUpdatedAt()); + self::assertSimilarDates(new DateTime(), $this->subject->getUpdatedAt()); } public function testGetPasswordHashInitiallyReturnsEmptyString(): void @@ -98,7 +101,7 @@ public function testGetPasswordChangeDateInitiallyReturnsNull(): void public function testSetPasswordHashSetsPasswordChangeDateToNow(): void { - $date = new \DateTime(); + $date = new DateTime(); $this->subject->setPasswordHash('Zaphod Beeblebrox'); self::assertSimilarDates($date, $this->subject->getPasswordChangeDate()); @@ -127,4 +130,42 @@ public function testSetSuperUserSetsSuperUser(): void self::assertTrue($this->subject->isSuperUser()); } + + public function testGetPrivilegesInitiallyReturnsEmptyPrivileges(): void + { + $privileges = $this->subject->getPrivileges(); + + self::assertInstanceOf(Privileges::class, $privileges); + + foreach (PrivilegeFlag::cases() as $flag) { + self::assertFalse($privileges->has($flag)); + } + } + + public function testSetPrivilegesSetsPrivileges(): void + { + $privileges = Privileges::fromSerialized(''); + $privileges = $privileges->grant(PrivilegeFlag::Subscribers); + + $this->subject->setPrivileges($privileges); + + $retrievedPrivileges = $this->subject->getPrivileges(); + self::assertTrue($retrievedPrivileges->has(PrivilegeFlag::Subscribers)); + self::assertFalse($retrievedPrivileges->has(PrivilegeFlag::Campaigns)); + } + + public function testSetPrivilegesWithMultiplePrivileges(): void + { + $privileges = Privileges::fromSerialized(''); + $privileges = $privileges + ->grant(PrivilegeFlag::Subscribers) + ->grant(PrivilegeFlag::Campaigns); + + $this->subject->setPrivileges($privileges); + + $retrievedPrivileges = $this->subject->getPrivileges(); + self::assertTrue($retrievedPrivileges->has(PrivilegeFlag::Subscribers)); + self::assertTrue($retrievedPrivileges->has(PrivilegeFlag::Campaigns)); + self::assertFalse($retrievedPrivileges->has(PrivilegeFlag::Statistics)); + } } diff --git a/tests/Unit/Domain/Identity/Model/PrivilegeFlagTest.php b/tests/Unit/Domain/Identity/Model/PrivilegeFlagTest.php new file mode 100644 index 00000000..d7a6ad26 --- /dev/null +++ b/tests/Unit/Domain/Identity/Model/PrivilegeFlagTest.php @@ -0,0 +1,45 @@ +value); + } + + public function testEnumHasCampaignsCase(): void + { + self::assertSame('campaigns', PrivilegeFlag::Campaigns->value); + } + + public function testEnumHasStatisticsCase(): void + { + self::assertSame('statistics', PrivilegeFlag::Statistics->value); + } + + public function testEnumHasSettingsCase(): void + { + self::assertSame('settings', PrivilegeFlag::Settings->value); + } + + public function testEnumHasFourCases(): void + { + $cases = PrivilegeFlag::cases(); + + self::assertCount(4, $cases); + self::assertContains(PrivilegeFlag::Subscribers, $cases); + self::assertContains(PrivilegeFlag::Campaigns, $cases); + self::assertContains(PrivilegeFlag::Statistics, $cases); + self::assertContains(PrivilegeFlag::Settings, $cases); + } +} diff --git a/tests/Unit/Domain/Identity/Model/PrivilegesTest.php b/tests/Unit/Domain/Identity/Model/PrivilegesTest.php new file mode 100644 index 00000000..e81b4afc --- /dev/null +++ b/tests/Unit/Domain/Identity/Model/PrivilegesTest.php @@ -0,0 +1,102 @@ +subject = Privileges::fromSerialized(''); + } + + public function testFromSerializedWithInvalidDataThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid serialized privileges string.'); + + Privileges::fromSerialized('invalid data'); + } + + public function testFromSerializedWithValidDataReturnsPopulatedPrivileges(): void + { + $data = [PrivilegeFlag::Subscribers->value => true]; + $serialized = serialize($data); + + $privileges = Privileges::fromSerialized($serialized); + + self::assertTrue($privileges->has(PrivilegeFlag::Subscribers)); + } + + public function testToSerializedReturnsSerializedData(): void + { + $privileges = Privileges::fromSerialized(''); + $privileges = $privileges->grant(PrivilegeFlag::Subscribers); + + $serialized = $privileges->toSerialized(); + $data = unserialize($serialized); + + self::assertTrue($data[PrivilegeFlag::Subscribers->value]); + } + + public function testHasReturnsFalseForUnsetPrivilege(): void + { + self::assertFalse($this->subject->has(PrivilegeFlag::Subscribers)); + } + + public function testHasReturnsTrueForSetPrivilege(): void + { + $this->subject = $this->subject->grant(PrivilegeFlag::Subscribers); + + self::assertTrue($this->subject->has(PrivilegeFlag::Subscribers)); + } + + public function testGrantSetsPrivilege(): void + { + $result = $this->subject->grant(PrivilegeFlag::Subscribers); + + self::assertTrue($result->has(PrivilegeFlag::Subscribers)); + } + + public function testGrantReturnsNewInstance(): void + { + $result = $this->subject->grant(PrivilegeFlag::Subscribers); + + self::assertNotSame($this->subject, $result); + } + + public function testRevokeClearsPrivilege(): void + { + $this->subject = $this->subject->grant(PrivilegeFlag::Subscribers); + $result = $this->subject->revoke(PrivilegeFlag::Subscribers); + + self::assertFalse($result->has(PrivilegeFlag::Subscribers)); + } + + public function testRevokeReturnsNewInstance(): void + { + $result = $this->subject->revoke(PrivilegeFlag::Subscribers); + + self::assertNotSame($this->subject, $result); + } + + public function testAllReturnsAllPrivileges(): void + { + $this->subject = $this->subject->grant(PrivilegeFlag::Subscribers); + $all = $this->subject->all(); + + self::assertTrue($all[PrivilegeFlag::Subscribers->value]); + self::assertFalse($all[PrivilegeFlag::Campaigns->value]); + } +} diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php index b557e2f0..e42aba74 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -20,7 +21,8 @@ class AdminAttributeDefinitionManagerTest extends TestCase protected function setUp(): void { $this->repository = $this->createMock(AdminAttributeDefinitionRepository::class); - $this->subject = new AdminAttributeDefinitionManager($this->repository); + $attributeTypeValidator = $this->createMock(AttributeTypeValidator::class); + $this->subject = new AdminAttributeDefinitionManager($this->repository, $attributeTypeValidator); } public function testCreateCreatesNewAttributeDefinition(): void diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php new file mode 100644 index 00000000..c310bd40 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -0,0 +1,312 @@ +messageRepository = $this->createMock(MessageRepository::class); + $this->mailer = $this->createMock(MailerInterface::class); + $lockFactory = $this->createMock(LockFactory::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->subscriberProvider = $this->createMock(SubscriberProvider::class); + $this->messageProcessingPreparator = $this->createMock(MessageProcessingPreparator::class); + $this->lock = $this->createMock(LockInterface::class); + + $lockFactory->method('createLock') + ->with('queue_processor') + ->willReturn($this->lock); + + $command = new ProcessQueueCommand( + $this->messageRepository, + $this->mailer, + $lockFactory, + $this->entityManager, + $this->subscriberProvider, + $this->messageProcessingPreparator + ); + + $application = new Application(); + $application->add($command); + + $this->commandTester = new CommandTester($command); + } + + public function testExecuteWithLockAlreadyAcquired(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(false); + + $this->messageProcessingPreparator->expects($this->never()) + ->method('ensureSubscribersHaveUuid'); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Queue is already being processed by another instance', $output); + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithNoCampaigns(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(true); + + $this->lock->expects($this->once()) + ->method('release'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureSubscribersHaveUuid'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureCampaignsHaveUuid'); + + $this->messageRepository->expects($this->once()) + ->method('findBy') + ->with(['status' => 'submitted']) + ->willReturn([]); + + $this->commandTester->execute([]); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithCampaigns(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(true); + + $this->lock->expects($this->once()) + ->method('release'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureSubscribersHaveUuid'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureCampaignsHaveUuid'); + + $campaign = $this->createMock(Message::class); + $metadata = $this->createMock(MessageMetadata::class); + $content = $this->createMock(MessageContent::class); + + $campaign->expects($this->any()) + ->method('getMetadata') + ->willReturn($metadata); + + $campaign->expects($this->any()) + ->method('getContent') + ->willReturn($content); + + $content->expects($this->any()) + ->method('getSubject') + ->willReturn('Test Subject'); + + $content->expects($this->any()) + ->method('getTextMessage') + ->willReturn('Test Text Message'); + + $content->expects($this->any()) + ->method('getText') + ->willReturn('Thank you for subscribing!
', $htmlContent); + $linkStart = ''; + $this->assertStringContainsString($linkStart, $htmlContent); + + return true; + })); + + $this->handler->__invoke($message); + } + + /** + * Helper method to extract email addresses from Address objects + */ + private function getEmailAddresses(array $addresses): array + { + return array_map(function ($address) { + return $address->getAddress(); + }, $addresses); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php new file mode 100644 index 00000000..9409320b --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php @@ -0,0 +1,305 @@ +mailer = $this->createMock(MailerInterface::class); + $this->messageBus = $this->createMock(MessageBusInterface::class); + $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail, $this->messageBus); + } + + public function testSendEmailWithDefaultFrom(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) { + $sentEmail = $message->getEmail(); + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email); + } + + public function testSendEmailSyncWithDefaultFrom(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (Email $sentEmail) { + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })); + + $this->emailService->sendEmailSync($email); + } + + public function testSendEmailWithCustomFrom(): void + { + $customFrom = 'custom@example.com'; + $email = (new Email()) + ->from($customFrom) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ($customFrom) { + $sentEmail = $message->getEmail(); + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($customFrom, $fromAddresses[0]->getAddress()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email); + } + + public function testSendEmailSyncWithCustomFrom(): void + { + $customFrom = 'custom@example.com'; + $email = (new Email()) + ->from($customFrom) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (Email $sentEmail) use ($customFrom) { + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($customFrom, $fromAddresses[0]->getAddress()); + return true; + })); + + $this->emailService->sendEmailSync($email); + } + + public function testSendEmailWithCcBccAndReplyTo(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $cc = ['cc@example.com']; + $bcc = ['bcc@example.com']; + $replyTo = ['reply@example.com']; + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ($cc, $bcc, $replyTo) { + $this->assertEquals($cc, $message->getCc()); + $this->assertEquals($bcc, $message->getBcc()); + $this->assertEquals($replyTo, $message->getReplyTo()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email, $cc, $bcc, $replyTo); + } + + public function testSendEmailSyncWithCcBccAndReplyTo(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $cc = ['cc@example.com']; + $bcc = ['bcc@example.com']; + $replyTo = ['reply@example.com']; + + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (Email $sentEmail) use ($cc, $bcc, $replyTo) { + $ccAddresses = $sentEmail->getCc(); + $bccAddresses = $sentEmail->getBcc(); + $replyToAddresses = $sentEmail->getReplyTo(); + + $this->assertCount(1, $ccAddresses); + $this->assertEquals($cc[0], $ccAddresses[0]->getAddress()); + + $this->assertCount(1, $bccAddresses); + $this->assertEquals($bcc[0], $bccAddresses[0]->getAddress()); + + $this->assertCount(1, $replyToAddresses); + $this->assertEquals($replyTo[0], $replyToAddresses[0]->getAddress()); + + return true; + })); + + $this->emailService->sendEmailSync($email, $cc, $bcc, $replyTo); + } + + public function testSendEmailWithAttachments(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $attachments = ['/path/to/attachment.pdf']; + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ($attachments) { + $this->assertEquals($attachments, $message->getAttachments()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email, [], [], [], $attachments); + } + + public function testSendEmailSyncWithAttachments(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $attachments = ['/path/to/attachment.pdf']; + + $this->mailer->expects($this->once()) + ->method('send'); + + $this->emailService->sendEmailSync($email, [], [], [], $attachments); + } + + public function testSendBulkEmail(): void + { + $recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + $html = 'Bulk Test HTML Content
'; + $from = 'sender@example.com'; + $fromName = 'Sender Name'; + + $this->messageBus->expects($this->exactly(count($recipients))) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ( + $subject, + $text, + $html, + $from, + $fromName + ) { + $sentEmail = $message->getEmail(); + $this->assertEquals($subject, $sentEmail->getSubject()); + $this->assertEquals($text, $sentEmail->getTextBody()); + $this->assertEquals($html, $sentEmail->getHtmlBody()); + + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($from, $fromAddresses[0]->getAddress()); + $this->assertEquals($fromName, $fromAddresses[0]->getName()); + + return true; + })) + ->willReturn(new Envelope($this->createMock(AsyncEmailMessage::class))); + + $this->emailService->sendBulkEmail($recipients, $subject, $text, $html, $from, $fromName); + } + + public function testSendBulkEmailSync(): void + { + $recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + $html = 'Bulk Test HTML Content
'; + $from = 'sender@example.com'; + $fromName = 'Sender Name'; + + $this->mailer->expects($this->exactly(count($recipients))) + ->method('send') + ->with($this->callback(function (Email $sentEmail) use ($subject, $text, $html, $from, $fromName) { + $this->assertEquals($subject, $sentEmail->getSubject()); + $this->assertEquals($text, $sentEmail->getTextBody()); + $this->assertEquals($html, $sentEmail->getHtmlBody()); + + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($from, $fromAddresses[0]->getAddress()); + $this->assertEquals($fromName, $fromAddresses[0]->getName()); + + return true; + })); + + $this->emailService->sendBulkEmailSync($recipients, $subject, $text, $html, $from, $fromName); + } + + public function testSendBulkEmailWithDefaultFrom(): void + { + $recipients = ['user1@example.com', 'user2@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + + $this->messageBus->expects($this->exactly(count($recipients))) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) { + $sentEmail = $message->getEmail(); + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })) + ->willReturn(new Envelope($this->createMock(AsyncEmailMessage::class))); + + $this->emailService->sendBulkEmail($recipients, $subject, $text); + } + + public function testSendBulkEmailSyncWithDefaultFrom(): void + { + $recipients = ['user1@example.com', 'user2@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + + $this->mailer->expects($this->exactly(count($recipients))) + ->method('send') + ->with($this->callback(function (Email $sentEmail) { + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })); + + $this->emailService->sendBulkEmailSync($recipients, $subject, $text); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php new file mode 100644 index 00000000..c2c0d0a5 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -0,0 +1,214 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->linkTrackService = $this->createMock(LinkTrackService::class); + $this->output = $this->createMock(OutputInterface::class); + + $this->preparator = new MessageProcessingPreparator( + $this->entityManager, + $this->subscriberRepository, + $this->messageRepository, + $this->linkTrackService + ); + } + + public function testEnsureSubscribersHaveUuidWithNoSubscribers(): void + { + $this->subscriberRepository->expects($this->once()) + ->method('findSubscribersWithoutUuid') + ->willReturn([]); + + $this->output->expects($this->never()) + ->method('writeln'); + + $this->entityManager->expects($this->never()) + ->method('flush'); + + $this->preparator->ensureSubscribersHaveUuid($this->output); + } + + public function testEnsureSubscribersHaveUuidWithSubscribers(): void + { + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber2 = $this->createMock(Subscriber::class); + + $subscribers = [$subscriber1, $subscriber2]; + + $this->subscriberRepository->expects($this->once()) + ->method('findSubscribersWithoutUuid') + ->willReturn($subscribers); + + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('Giving a UUID to 2 subscribers')); + + $subscriber1->expects($this->once()) + ->method('setUniqueId') + ->with($this->isType('string')); + + $subscriber2->expects($this->once()) + ->method('setUniqueId') + ->with($this->isType('string')); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->preparator->ensureSubscribersHaveUuid($this->output); + } + + public function testEnsureCampaignsHaveUuidWithNoCampaigns(): void + { + $this->messageRepository->expects($this->once()) + ->method('findCampaignsWithoutUuid') + ->willReturn([]); + + $this->output->expects($this->never()) + ->method('writeln'); + + $this->entityManager->expects($this->never()) + ->method('flush'); + + $this->preparator->ensureCampaignsHaveUuid($this->output); + } + + public function testEnsureCampaignsHaveUuidWithCampaigns(): void + { + $campaign1 = $this->createMock(Message::class); + $campaign2 = $this->createMock(Message::class); + + $campaigns = [$campaign1, $campaign2]; + + $this->messageRepository->expects($this->once()) + ->method('findCampaignsWithoutUuid') + ->willReturn($campaigns); + + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('Giving a UUID to 2 campaigns')); + + $campaign1->expects($this->once()) + ->method('setUuid') + ->with($this->isType('string')); + + $campaign2->expects($this->once()) + ->method('setUuid') + ->with($this->isType('string')); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->preparator->ensureCampaignsHaveUuid($this->output); + } + + public function testProcessMessageLinksWhenLinkTrackingNotApplicable(): void + { + $message = $this->createMock(Message::class); + $userId = 123; + + $this->linkTrackService->expects($this->once()) + ->method('isExtractAndSaveLinksApplicable') + ->willReturn(false); + + $this->linkTrackService->expects($this->never()) + ->method('extractAndSaveLinks'); + + $message->expects($this->never()) + ->method('getContent'); + + $result = $this->preparator->processMessageLinks($message, $userId); + + $this->assertSame($message, $result); + } + + public function testProcessMessageLinksWhenNoLinksExtracted(): void + { + $message = $this->createMock(Message::class); + $userId = 123; + + $this->linkTrackService->expects($this->once()) + ->method('isExtractAndSaveLinksApplicable') + ->willReturn(true); + + $this->linkTrackService->expects($this->once()) + ->method('extractAndSaveLinks') + ->with($message, $userId) + ->willReturn([]); + + $message->expects($this->never()) + ->method('getContent'); + + $result = $this->preparator->processMessageLinks($message, $userId); + + $this->assertSame($message, $result); + } + + public function testProcessMessageLinksWithLinksExtracted(): void + { + $message = $this->createMock(Message::class); + $content = $this->createMock(MessageContent::class); + $userId = 123; + + $linkTrack1 = $this->createMock(LinkTrack::class); + $linkTrack1->method('getId')->willReturn(1); + $linkTrack1->method('getUrl')->willReturn('https://example.com'); + + $linkTrack2 = $this->createMock(LinkTrack::class); + $linkTrack2->method('getId')->willReturn(2); + $linkTrack2->method('getUrl')->willReturn('https://example.org'); + + $savedLinks = [$linkTrack1, $linkTrack2]; + + $this->linkTrackService->method('isExtractAndSaveLinksApplicable')->willReturn(true); + $this->linkTrackService->method('extractAndSaveLinks')->with($message, $userId)->willReturn($savedLinks); + + $message->method('getContent')->willReturn($content); + + $htmlContent = 'Link 1 Link 2'; + $content->method('getText')->willReturn($htmlContent); + + $footer = 'Footer Link'; + $content->method('getFooter')->willReturn($footer); + + $content->expects($this->once()) + ->method('setText') + ->with($this->stringContains(MessageProcessingPreparator::LINT_TRACK_ENDPOINT . '?id=1')); + + $content->expects($this->once()) + ->method('setFooter') + ->with($this->stringContains(MessageProcessingPreparator::LINT_TRACK_ENDPOINT . '?id=1')); + + $result = $this->preparator->processMessageLinks($message, $userId); + + $this->assertSame($message, $result); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php index bc5a6f18..85b3bb93 100644 --- a/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager; +use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; use PHPUnit\Framework\TestCase; class AttributeDefinitionManagerTest extends TestCase @@ -16,7 +17,8 @@ class AttributeDefinitionManagerTest extends TestCase public function testCreateAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $dto = new AttributeDefinitionDto( name: 'Country', @@ -48,7 +50,8 @@ public function testCreateAttributeDefinition(): void public function testCreateThrowsWhenAttributeAlreadyExists(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $dto = new AttributeDefinitionDto( name: 'Country', @@ -74,7 +77,8 @@ public function testCreateThrowsWhenAttributeAlreadyExists(): void public function testUpdateAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $attribute = new SubscriberAttributeDefinition(); $attribute->setName('Old'); @@ -108,7 +112,8 @@ public function testUpdateAttributeDefinition(): void public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $dto = new AttributeDefinitionDto( name: 'Existing', @@ -138,7 +143,8 @@ public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void public function testDeleteAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $attribute = new SubscriberAttributeDefinition(); diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php new file mode 100644 index 00000000..73430c97 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php @@ -0,0 +1,141 @@ +listMessageRepository = $this->createMock(ListMessageRepository::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + + $this->subscriberProvider = new SubscriberProvider( + $this->listMessageRepository, + $this->subscriberRepository + ); + } + + public function testGetSubscribersForMessageWithNoListsReturnsEmptyArray(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([]); + + $this->subscriberRepository + ->expects($this->never()) + ->method('getSubscribersBySubscribedListId'); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetSubscribersForMessageWithOneListButNoSubscribersReturnsEmptyArray(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([456]); + + $this->subscriberRepository + ->expects($this->once()) + ->method('getSubscribersBySubscribedListId') + ->with(456) + ->willReturn([]); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubscribers(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getId')->willReturn(1); + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getId')->willReturn(2); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([456]); + + $this->subscriberRepository + ->expects($this->once()) + ->method('getSubscribersBySubscribedListId') + ->with(456) + ->willReturn([$subscriber1, $subscriber2]); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame($subscriber1, $result[0]); + $this->assertSame($subscriber2, $result[1]); + } + + public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscribers(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getId')->willReturn(1); + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getId')->willReturn(2); + $subscriber3 = $this->createMock(Subscriber::class); + $subscriber3->method('getId')->willReturn(3); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([456, 789]); + + $this->subscriberRepository + ->expects($this->exactly(2)) + ->method('getSubscribersBySubscribedListId') + ->willReturnMap([ + [456, [$subscriber1, $subscriber2]], + [789, [$subscriber2, $subscriber3]], + ]); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertCount(3, $result); + + $this->assertContains($subscriber1, $result); + $this->assertContains($subscriber2, $result); + $this->assertContains($subscriber3, $result); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php index c3aeef68..046ae625 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php @@ -14,6 +14,7 @@ use PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; class SubscriberCsvExportManagerTest extends TestCase @@ -32,7 +33,8 @@ protected function setUp(): void $this->subject = new SubscriberCsvExporter( $this->attributeManagerMock, $this->subscriberRepositoryMock, - $this->attributeDefinitionRepositoryMock + $this->attributeDefinitionRepositoryMock, + $this->createMock(LoggerInterface::class) ); } diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php new file mode 100644 index 00000000..27bd85db --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -0,0 +1,200 @@ +linkTrackUmlClickRepository = $this->createMock(LinkTrackUmlClickRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->userMessageRepository = $this->createMock(UserMessageRepository::class); + $this->subscriberAttributeValueRepository = $this->createMock(SubscriberAttributeValueRepository::class); + $this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryRepository::class); + $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->userMessageForwardRepository = $this->createMock(UserMessageForwardRepository::class); + $this->userMessageViewRepository = $this->createMock(UserMessageViewRepository::class); + $this->subscriptionRepository = $this->createMock(SubscriptionRepository::class); + + $this->service = new SubscriberDeletionService( + $this->linkTrackUmlClickRepository, + $this->entityManager, + $this->userMessageRepository, + $this->subscriberAttributeValueRepository, + $this->subscriberHistoryRepository, + $this->userMessageBounceRepository, + $this->userMessageForwardRepository, + $this->userMessageViewRepository, + $this->subscriptionRepository, + ); + } + + public function testDeleteLeavingBlacklistRemovesAllRelatedData(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriberId = 123; + $subscriber->method('getId')->willReturn($subscriberId); + + $subscription = $this->createMock(Subscription::class); + $this->subscriptionRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([$subscription]); + + $linkTrackUmlClick = $this->createMock(LinkTrackUmlClick::class); + $this->linkTrackUmlClickRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$linkTrackUmlClick]); + + $this->entityManager + ->expects($this->atLeast(1)) + ->method('remove'); + + $userMessage = $this->createMock(UserMessage::class); + $this->userMessageRepository + ->method('findBy') + ->with(['user' => $subscriber]) + ->willReturn([$userMessage]); + + $subscriberAttribute = $this->createMock(SubscriberAttributeValue::class); + $this->subscriberAttributeValueRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([$subscriberAttribute]); + + $subscriberHistory = $this->createMock(SubscriberHistory::class); + $this->subscriberHistoryRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([$subscriberHistory]); + + $userMessageBounce = $this->createMock(UserMessageBounce::class); + $this->userMessageBounceRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$userMessageBounce]); + + $userMessageForward = $this->createMock(UserMessageForward::class); + $this->userMessageForwardRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$userMessageForward]); + + $userMessageView = $this->createMock(UserMessageView::class); + $this->userMessageViewRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$userMessageView]); + + $this->service->deleteLeavingBlacklist($subscriber); + } + + public function testDeleteLeavingBlacklistHandlesEmptyRelatedData(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriberId = 123; + $subscriber->method('getId')->willReturn($subscriberId); + + $this->subscriptionRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([]); + + $this->linkTrackUmlClickRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + + $this->userMessageRepository + ->method('findBy') + ->with(['user' => $subscriber]) + ->willReturn([]); + $this->userMessageRepository + ->expects($this->never()) + ->method('remove'); + + $this->subscriberAttributeValueRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([]); + $this->subscriberAttributeValueRepository + ->expects($this->never()) + ->method('remove'); + + $this->subscriberHistoryRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([]); + $this->subscriberHistoryRepository + ->expects($this->never()) + ->method('remove'); + + $this->userMessageBounceRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + $this->userMessageBounceRepository + ->expects($this->never()) + ->method('remove'); + + $this->userMessageForwardRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + $this->userMessageForwardRepository + ->expects($this->never()) + ->method('remove'); + + $this->userMessageViewRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + $this->userMessageViewRepository + ->expects($this->never()) + ->method('remove'); + + $this->entityManager + ->expects($this->once()) + ->method('remove') + ->with($subscriber); + + $this->service->deleteLeavingBlacklist($subscriber); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php index ef233294..7d246b7d 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php @@ -5,22 +5,72 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PHPUnit\Framework\MockObject\MockObject; +use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use PHPUnit\Framework\TestCase; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; class SubscriberManagerTest extends TestCase { + private SubscriberRepository|MockObject $subscriberRepository; + private EntityManagerInterface|MockObject $entityManager; + private MessageBusInterface|MockObject $messageBus; + private SubscriberDeletionService|MockObject $subscriberDeletionService; + private SubscriberManager $subscriberManager; + + protected function setUp(): void + { + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->messageBus = $this->createMock(MessageBusInterface::class); + $this->subscriberDeletionService = $this->createMock(SubscriberDeletionService::class); + + $this->subscriberManager = new SubscriberManager( + $this->subscriberRepository, + $this->entityManager, + $this->messageBus, + $this->subscriberDeletionService + ); + } + public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity(): void { - $repoMock = $this->createMock(SubscriberRepository::class); - $emMock = $this->createMock(EntityManagerInterface::class); - $repoMock + $this->subscriberRepository + ->expects($this->once()) + ->method('save') + ->with($this->callback(function (Subscriber $sub): bool { + return $sub->getEmail() === 'foo@bar.com' + && $sub->isConfirmed() === true + && $sub->isBlacklisted() === false + && $sub->hasHtmlEmail() === true + && $sub->isDisabled() === false; + })); + + $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: false, htmlEmail: true); + + $result = $this->subscriberManager->createSubscriber($dto); + + $this->assertInstanceOf(Subscriber::class, $result); + $this->assertSame('foo@bar.com', $result->getEmail()); + $this->assertTrue($result->isConfirmed()); + $this->assertFalse($result->isBlacklisted()); + $this->assertTrue($result->hasHtmlEmail()); + $this->assertFalse($result->isDisabled()); + } + + public function testCreateSubscriberPersistsAndSendsEmail(): void + { + $this->subscriberRepository ->expects($this->once()) ->method('save') ->with($this->callback(function (Subscriber $sub): bool { + $sub->setUniqueId('test-unique-id-456'); return $sub->getEmail() === 'foo@bar.com' && $sub->isConfirmed() === false && $sub->isBlacklisted() === false @@ -28,17 +78,69 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity( && $sub->isDisabled() === false; })); - $manager = new SubscriberManager($repoMock, $emMock); + $this->messageBus + ->expects($this->once()) + ->method('dispatch') + ->willReturnCallback(function ($message) { + return new Envelope($message); + }); $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: true, htmlEmail: true); - $result = $manager->createSubscriber($dto); + $result = $this->subscriberManager->createSubscriber($dto); - $this->assertInstanceOf(Subscriber::class, $result); $this->assertSame('foo@bar.com', $result->getEmail()); $this->assertFalse($result->isConfirmed()); $this->assertFalse($result->isBlacklisted()); $this->assertTrue($result->hasHtmlEmail()); $this->assertFalse($result->isDisabled()); } + + public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): void + { + $capturedSubscriber = null; + $this->subscriberRepository + ->expects($this->once()) + ->method('save') + ->with($this->callback(function (Subscriber $subscriber) use (&$capturedSubscriber) { + $capturedSubscriber = $subscriber; + $subscriber->setUniqueId('test-unique-id-123'); + return true; + })); + + $this->messageBus + ->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (SubscriberConfirmationMessage $message) { + $this->assertEquals('test@example.com', $message->getEmail()); + $this->assertEquals('test-unique-id-123', $message->getUniqueId()); + $this->assertTrue($message->hasHtmlEmail()); + return true; + })) + ->willReturnCallback(function ($message) { + return new Envelope($message); + }); + + $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: true, htmlEmail: true); + $this->subscriberManager->createSubscriber($dto); + + $this->assertNotNull($capturedSubscriber); + $this->assertEquals('test@example.com', $capturedSubscriber->getEmail()); + $this->assertTrue($capturedSubscriber->hasHtmlEmail()); + $this->assertFalse($capturedSubscriber->isConfirmed()); + } + + public function testCreateSubscriberWithoutConfirmationDoesNotSendConfirmationEmail(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('save'); + + $this->messageBus + ->expects($this->never()) + ->method('dispatch'); + + $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: false, htmlEmail: true); + $this->subscriberManager->createSubscriber($dto); + } } diff --git a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php new file mode 100644 index 00000000..c0ab3a5a --- /dev/null +++ b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php @@ -0,0 +1,45 @@ +validator = new AttributeTypeValidator(); + } + + public function testValidatesValidType(): void + { + $this->validator->validate('textline'); + $this->validator->validate('checkbox'); + $this->validator->validate('date'); + + $this->assertTrue(true); + } + + public function testThrowsExceptionForInvalidType(): void + { + $this->expectException(ValidatorException::class); + $this->expectExceptionMessage('Invalid attribute type: "invalid_type"'); + + $this->validator->validate('invalid_type'); + } + + public function testThrowsExceptionForNonStringValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Value must be a string.'); + + $this->validator->validate(123); + } +}