diff --git a/composer.json b/composer.json index 285f0d3e..8eb8e037 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,9 @@ "symfony/validator": "^6.4", "doctrine/doctrine-fixtures-bundle": "^3.7", "doctrine/instantiator": "^2.0", - "masterminds/html5": "^2.9" + "masterminds/html5": "^2.9", + "ext-dom": "*", + "league/csv": "^9.23.0" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index 7dbe0383..8c88111a 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -17,6 +17,7 @@ \PHPUnit\DbUnit\Operation\Factory, \Symfony\Component\Debug\Debug, \Symfony\Component\Yaml\Yaml, + \League\Csv\Reader, "/> diff --git a/config/config.yml b/config/config.yml index 7d4125b3..f4cf974a 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,6 +1,5 @@ imports: - { resource: services.yml } - - { resource: repositories.yml } - { resource: doctrine.yml } # Put parameters here that don't need to change on each machine where the app is deployed diff --git a/config/doctrine.yml b/config/doctrine.yml index 10f7e1c0..327cf305 100644 --- a/config/doctrine.yml +++ b/config/doctrine.yml @@ -15,12 +15,12 @@ doctrine: orm: auto_generate_proxy_classes: '%kernel.debug%' naming_strategy: doctrine.orm.naming_strategy.underscore - auto_mapping: true + auto_mapping: false mappings: - PhpList\Core\Domain\Model: + Identity: is_bundle: false type: attribute - dir: '%kernel.project_dir%/src/Domain/Model/' - prefix: 'PhpList\Core\Domain\Model\' + dir: '%kernel.project_dir%/src/Domain/Identity/Model' + prefix: 'PhpList\Core\Domain\Identity\Model' controller_resolver: auto_mapping: false diff --git a/config/packages/app.yml b/config/packages/app.yml new file mode 100644 index 00000000..61cf7460 --- /dev/null +++ b/config/packages/app.yml @@ -0,0 +1,10 @@ +app: + config: + message_from_address: 'news@example.com' + admin_address: 'admin@example.com' + default_message_age: 15768000 + message_footer: 'Thanks for reading' + forward_footer: 'Forwarded message' + notify_start_default: 'start@example.com' + notify_end_default: 'end@example.com' + always_add_google_tracking: true diff --git a/config/repositories.yml b/config/repositories.yml deleted file mode 100644 index 2373b0dc..00000000 --- a/config/repositories.yml +++ /dev/null @@ -1,42 +0,0 @@ -services: - PhpList\Core\Domain\Repository\Identity\AdministratorRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Identity\Administrator - - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata - - PhpList\Core\Security\HashGenerator - - PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Identity\AdministratorToken - - PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Subscription\SubscriberList - - PhpList\Core\Domain\Repository\Subscription\SubscriberRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Subscription\Subscriber - - PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Subscription\Subscription - - PhpList\Core\Domain\Repository\Messaging\MessageRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Messaging\Message - - PhpList\Core\Domain\Repository\Messaging\TemplateRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Messaging\Template - - PhpList\Core\Domain\Repository\Messaging\TemplateImageRepository: - parent: PhpList\Core\Domain\Repository - arguments: - - PhpList\Core\Domain\Model\Messaging\TemplateImage diff --git a/config/services.yml b/config/services.yml index d7982241..b83adce3 100644 --- a/config/services.yml +++ b/config/services.yml @@ -1,14 +1,16 @@ +imports: + - { resource: 'services/*.yml' } + services: - # default configuration for services in *this* file _defaults: - # automatically injects dependencies in your services autowire: true - # automatically registers your services as commands, event subscribers, etc. autoconfigure: true - # this means you cannot fetch services directly from the container via $container->get() - # if you need to do this, you can override this setting on individual services public: false + PhpList\Core\Core\ConfigProvider: + arguments: + $config: '%app.config%' + PhpList\Core\Core\ApplicationStructure: public: true @@ -21,7 +23,7 @@ services: PhpList\Core\Routing\ExtraLoader: tags: [routing.loader] - PhpList\Core\Domain\Repository: + PhpList\Core\Domain\Common\Repository\AbstractRepository: abstract: true autowire: true autoconfigure: false diff --git a/config/services/builders.yml b/config/services/builders.yml new file mode 100644 index 00000000..c18961d6 --- /dev/null +++ b/config/services/builders.yml @@ -0,0 +1,25 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder: + autowire: true + autoconfigure: true + + PhpListPhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: + autowire: true + autoconfigure: true diff --git a/config/services/managers.yml b/config/services/managers.yml new file mode 100644 index 00000000..cdb1eb63 --- /dev/null +++ b/config/services/managers.yml @@ -0,0 +1,63 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Identity\Service\SessionManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\MessageManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\TemplateManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\TemplateImageManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Identity\Service\AdministratorManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Identity\Service\AdminAttributeManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter: + autowire: true + autoconfigure: true + public: true diff --git a/config/services/mappers.yml b/config/services/mappers.yml new file mode 100644 index 00000000..f84bd717 --- /dev/null +++ b/config/services/mappers.yml @@ -0,0 +1,13 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Subscription\Service\CsvRowToDtoMapper: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\CsvImporter: + autowire: true + autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml new file mode 100644 index 00000000..21ce7114 --- /dev/null +++ b/config/services/repositories.yml @@ -0,0 +1,62 @@ +services: + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\Administrator + - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata + - PhpList\Core\Security\HashGenerator + + PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminAttributeValue + + PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition + + PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdministratorToken + + PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberList + + PhpList\Core\Domain\Subscription\Repository\SubscriberRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\Subscriber + + PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue + + PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition + + PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\Subscription + + PhpList\Core\Domain\Messaging\Repository\MessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Message + + PhpList\Core\Domain\Messaging\Repository\TemplateRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Template + + PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\TemplateImage diff --git a/config/services/validators.yml b/config/services/validators.yml new file mode 100644 index 00000000..3d15e4a5 --- /dev/null +++ b/config/services/validators.yml @@ -0,0 +1,8 @@ +services: + PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator: + autowire: true + autoconfigure: true diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php index 695520a1..1d3c64ab 100644 --- a/src/Core/ApplicationKernel.php +++ b/src/Core/ApplicationKernel.php @@ -105,6 +105,7 @@ private function getAndCreateApplicationStructure(): ApplicationStructure protected function build(ContainerBuilder $container): void { $container->setParameter('kernel.application_dir', $this->getApplicationDir()); + $container->addCompilerPass(new DoctrineMappingPass()); } /** diff --git a/src/Core/ConfigProvider.php b/src/Core/ConfigProvider.php new file mode 100644 index 00000000..b78f365f --- /dev/null +++ b/src/Core/ConfigProvider.php @@ -0,0 +1,22 @@ +config[$key] ?? $default; + } + + public function all(): array + { + return $this->config; + } +} diff --git a/src/Core/DoctrineMappingPass.php b/src/Core/DoctrineMappingPass.php new file mode 100644 index 00000000..3d60a886 --- /dev/null +++ b/src/Core/DoctrineMappingPass.php @@ -0,0 +1,45 @@ +getParameter('kernel.project_dir'); + $basePath = $projectDir . '/src/Domain'; + + $driverDefinition = $container->getDefinition('doctrine.orm.default_metadata_driver'); + + foreach (scandir($basePath) as $dir) { + if ($dir === '.' || $dir === '..') { + continue; + } + + $modelPath = $basePath . '/' . $dir . '/Model'; + if (!is_dir($modelPath)) { + continue; + } + + $namespace = 'PhpList\\Core\\Domain\\' . $dir . '\\Model'; + + $attributeDriverDef = new Definition(AttributeDriver::class, [[$modelPath]]); + $attributeDriverId = 'doctrine.orm.driver.' . $dir; + + $container->setDefinition($attributeDriverId, $attributeDriverDef); + + $driverDefinition->addMethodCall('addDriver', [ + new Reference($attributeDriverId), + $namespace, + ]); + } + } +} diff --git a/src/Domain/Model/Analytics/LinkTrack.php b/src/Domain/Analytics/Model/LinkTrack.php similarity index 92% rename from src/Domain/Model/Analytics/LinkTrack.php rename to src/Domain/Analytics/Model/LinkTrack.php index af8219bc..2dc32de6 100644 --- a/src/Domain/Model/Analytics/LinkTrack.php +++ b/src/Domain/Analytics/Model/LinkTrack.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Analytics; +namespace PhpList\Core\Domain\Analytics\Model; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Analytics\LinkTrackRepository; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: LinkTrackRepository::class)] #[ORM\Table(name: 'phplist_linktrack')] diff --git a/src/Domain/Model/Analytics/LinkTrackForward.php b/src/Domain/Analytics/Model/LinkTrackForward.php similarity index 89% rename from src/Domain/Model/Analytics/LinkTrackForward.php rename to src/Domain/Analytics/Model/LinkTrackForward.php index 213ff342..0dcf7d55 100644 --- a/src/Domain/Model/Analytics/LinkTrackForward.php +++ b/src/Domain/Analytics/Model/LinkTrackForward.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Analytics; +namespace PhpList\Core\Domain\Analytics\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Analytics\LinkTrackForwardRepository; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackForwardRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: LinkTrackForwardRepository::class)] #[ORM\Table(name: 'phplist_linktrack_forward')] diff --git a/src/Domain/Model/Analytics/LinkTrackMl.php b/src/Domain/Analytics/Model/LinkTrackMl.php similarity index 94% rename from src/Domain/Model/Analytics/LinkTrackMl.php rename to src/Domain/Analytics/Model/LinkTrackMl.php index 72bdabcc..8569fa14 100644 --- a/src/Domain/Model/Analytics/LinkTrackMl.php +++ b/src/Domain/Analytics/Model/LinkTrackMl.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Analytics; +namespace PhpList\Core\Domain\Analytics\Model; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Analytics\LinkTrackMlRepository; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; #[ORM\Entity(repositoryClass: LinkTrackMlRepository::class)] #[ORM\Table(name: 'phplist_linktrack_ml')] diff --git a/src/Domain/Model/Analytics/LinkTrackUmlClick.php b/src/Domain/Analytics/Model/LinkTrackUmlClick.php similarity index 93% rename from src/Domain/Model/Analytics/LinkTrackUmlClick.php rename to src/Domain/Analytics/Model/LinkTrackUmlClick.php index 8cff6c1b..2b8c8068 100644 --- a/src/Domain/Model/Analytics/LinkTrackUmlClick.php +++ b/src/Domain/Analytics/Model/LinkTrackUmlClick.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Analytics; +namespace PhpList\Core\Domain\Analytics\Model; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Analytics\LinkTrackUmlClickRepository; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: LinkTrackUmlClickRepository::class)] #[ORM\Table(name: 'phplist_linktrack_uml_click')] diff --git a/src/Domain/Model/Analytics/LinkTrackUserClick.php b/src/Domain/Analytics/Model/LinkTrackUserClick.php similarity index 93% rename from src/Domain/Model/Analytics/LinkTrackUserClick.php rename to src/Domain/Analytics/Model/LinkTrackUserClick.php index e2e33cce..464ae3e0 100644 --- a/src/Domain/Model/Analytics/LinkTrackUserClick.php +++ b/src/Domain/Analytics/Model/LinkTrackUserClick.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Analytics; +namespace PhpList\Core\Domain\Analytics\Model; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Analytics\LinkTrackUserClickRepository; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackUserClickRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; #[ORM\Entity(repositoryClass: LinkTrackUserClickRepository::class)] #[ORM\Table(name: 'phplist_linktrack_userclick')] diff --git a/src/Domain/Model/Analytics/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php similarity index 90% rename from src/Domain/Model/Analytics/UserMessageView.php rename to src/Domain/Analytics/Model/UserMessageView.php index d933e545..6b80b75e 100644 --- a/src/Domain/Model/Analytics/UserMessageView.php +++ b/src/Domain/Analytics/Model/UserMessageView.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Analytics; +namespace PhpList\Core\Domain\Analytics\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Analytics\UserMessageViewRepository; +use PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: UserMessageViewRepository::class)] #[ORM\Table(name: 'phplist_user_message_view')] diff --git a/src/Domain/Model/Analytics/UserStats.php b/src/Domain/Analytics/Model/UserStats.php similarity index 89% rename from src/Domain/Model/Analytics/UserStats.php rename to src/Domain/Analytics/Model/UserStats.php index 4907bb52..a2f835bf 100644 --- a/src/Domain/Model/Analytics/UserStats.php +++ b/src/Domain/Analytics/Model/UserStats.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Analytics; +namespace PhpList\Core\Domain\Analytics\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Analytics\UserStatsRepository; +use PhpList\Core\Domain\Analytics\Repository\UserStatsRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: UserStatsRepository::class)] #[ORM\Table(name: 'phplist_userstats')] diff --git a/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php b/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php new file mode 100644 index 00000000..38de3f74 --- /dev/null +++ b/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php @@ -0,0 +1,14 @@ +createQueryBuilder('lt') + ->where('lt.messageId = :messageId') + ->setParameter('messageId', $messageId) + ->andWhere('lt.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('lt.id', 'ASC'); + + if ($limit !== null) { + $query->setMaxResults($limit); + } + + return $query->getQuery()->getResult(); + } +} diff --git a/src/Domain/Analytics/Repository/LinkTrackUmlClickRepository.php b/src/Domain/Analytics/Repository/LinkTrackUmlClickRepository.php new file mode 100644 index 00000000..5662d672 --- /dev/null +++ b/src/Domain/Analytics/Repository/LinkTrackUmlClickRepository.php @@ -0,0 +1,14 @@ +createQueryBuilder('umv') + ->select('COUNT(umv.id)') + ->where('umv.message_id = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Domain/Analytics/Repository/UserStatsRepository.php b/src/Domain/Analytics/Repository/UserStatsRepository.php new file mode 100644 index 00000000..7786b148 --- /dev/null +++ b/src/Domain/Analytics/Repository/UserStatsRepository.php @@ -0,0 +1,14 @@ +linkTrackManager = $linkTrackManager; + $this->userMessageViewManager = $userMessageViewManager; + $this->messageRepository = $messageRepository; + $this->messageBounceRepository = $messageBounceRepository; + $this->messageForwardRepository = $messageForwardRepository; + $this->subscriberRepository = $subscriberRepository; + } + + /** + * Get campaign statistics + * + * Returns statistics overview for campaigns including: + * - Campaign (message) ID + * - Date sent + * - Sent count + * - Bounces + * - Forwards + * - Unique views + * - Total clicks + * - Unique clicks + * + * @param int $limit Maximum number of campaigns to return + * @param int $lastId Last seen campaign ID for pagination + * @return array + */ + public function getCampaignStatistics(int $limit = 50, int $lastId = 0): array + { + $messages = $this->messageRepository->getFilteredAfterId($lastId, $limit); + + $campaignStats = []; + + foreach ($messages as $message) { + $views = $this->userMessageViewManager->countViewsByMessageId($message->getId()); + $linkTracks = $this->linkTrackManager->getLinkTracksByMessageId($message->getId()); + + $totalClicks = 0; + $uniqueClickers = []; + + foreach ($linkTracks as $linkTrack) { + $totalClicks += $linkTrack->getClicked(); + $uniqueClickers[$linkTrack->getUserId()] = true; + } + + $uniqueClicks = count($uniqueClickers); + $bounces = $this->messageBounceRepository->getCountByMessageId($message->getId()); + $forwards = $this->messageForwardRepository->getCountByMessageId($message->getId()); + $sentDate = $message->getMetadata()->getSent(); + $sentCount = $message->getMetadata()->getBounceCount() + $views; + + $campaignStats[] = [ + 'campaignId' => $message->getId(), + 'subject' => $message->getContent()->getSubject(), + 'dateSent' => $sentDate?->format('Y-m-d H:i:s'), + 'sent' => $sentCount, + 'bounces' => $bounces, + 'forwards' => $forwards, + 'uniqueViews' => $views, + 'totalClicks' => $totalClicks, + 'uniqueClicks' => $uniqueClicks, + ]; + } + + return [ + 'campaigns' => $campaignStats, + 'total' => count($campaignStats), + 'hasMore' => count($messages) === $limit, + 'lastId' => count($messages) > 0 ? $messages[count($messages) - 1]->getId() : $lastId, + ]; + } + + /** + * Get view opens statistics + * + * Returns statistics for view opens including: + * - Available campaigns + * - Sent count + * - Unique Views + * - Rate (percentage of views to sent) + * + * @param int $limit Maximum number of campaigns to return + * @param int $lastId Last seen campaign ID for pagination + * @return array + */ + public function getViewOpensStatistics(int $limit = 50, int $lastId = 0): array + { + $messages = $this->messageRepository->getFilteredAfterId($lastId, $limit); + + $viewStats = []; + + foreach ($messages as $message) { + $views = $this->userMessageViewManager->countViewsByMessageId($message->getId()); + $sentCount = $message->getMetadata()->getBounceCount() + $views; + + $viewRate = $this->formatStat($views, $sentCount); + + $viewStats[] = [ + 'campaignId' => $message->getId(), + 'subject' => $message->getContent()->getSubject(), + 'sent' => $sentCount, + 'uniqueViews' => $views, + 'rate' => $viewRate, + ]; + } + + return [ + 'campaigns' => $viewStats, + 'total' => count($viewStats), + 'hasMore' => count($messages) === $limit, + 'lastId' => count($messages) > 0 ? $messages[count($messages) - 1]->getId() : $lastId, + ]; + } + + /** + * Get top domains with more than 5 subscribers + * + * Returns statistics for the top 50 domains with more than 5 subscribers: + * - Domain name + * - Number of subscribers + * + * @param int $limit Maximum number of domains to return (default: 50) + * @param int $minSubscribers Minimum number of subscribers per domain (default: 5) + * @return array + */ + public function getTopDomains(int $limit = 50, int $minSubscribers = 5): array + { + $domains = []; + + $subscribers = $this->subscriberRepository->findAll(); + + foreach ($subscribers as $subscriber) { + $email = $subscriber->getEmail(); + $domain = substr(strrchr($email, '@'), 1) ?: ''; + + if (!empty($domain)) { + if (!isset($domains[$domain])) { + $domains[$domain] = 0; + } + $domains[$domain]++; + } + } + + $filteredDomains = array_filter($domains, function ($count) use ($minSubscribers) { + return $count >= $minSubscribers; + }); + + arsort($filteredDomains); + + $result = []; + $count = 0; + foreach ($filteredDomains as $domain => $subscriberCount) { + if ($count >= $limit) { + break; + } + + $result[] = [ + 'domain' => $domain, + 'subscribers' => $subscriberCount, + ]; + + $count++; + } + + return [ + 'domains' => $result, + 'total' => count($result), + ]; + } + + /** + * Get domains with most unconfirmed subscribers + * + * Returns statistics for domains showing: + * - Domain name + * - Confirmed subscribers count and percentage + * - Unconfirmed subscribers count and percentage + * - Blacklisted subscribers count and percentage + * - Total subscribers count and percentage + * + * @param int $limit Maximum number of domains to return (default: 50) + * @return array + */ + public function getDomainConfirmationStatistics(int $limit = 50): array + { + $domains = []; + + $subscribers = $this->subscriberRepository->findAll(); + + foreach ($subscribers as $subscriber) { + $email = $subscriber->getEmail(); + $domain = substr(strrchr($email, '@'), 1) ?: ''; + + if (!empty($domain)) { + if (!isset($domains[$domain])) { + $domains[$domain] = [ + 'confirmed' => 0, + 'unconfirmed' => 0, + 'blacklisted' => 0, + 'total' => 0, + ]; + } + + $domains[$domain]['total']++; + + if ($subscriber->isBlacklisted()) { + $domains[$domain]['blacklisted']++; + } elseif ($subscriber->isConfirmed()) { + $domains[$domain]['confirmed']++; + } else { + $domains[$domain]['unconfirmed']++; + } + } + } + + uasort($domains, function ($domain1, $domain2) { + return $domain2['unconfirmed'] <=> $domain1['unconfirmed']; + }); + + $result = []; + $count = 0; + foreach ($domains as $domain => $stats) { + if ($count >= $limit) { + break; + } + + $domainTotal = $stats['total']; + + $result[] = [ + 'domain' => $domain, + 'confirmed' => [ + 'count' => $stats['confirmed'], + 'percentage' => $this->formatStat($stats['confirmed'], $domainTotal) + ], + 'unconfirmed' => [ + 'count' => $stats['unconfirmed'], + 'percentage' => $this->formatStat($stats['unconfirmed'], $domainTotal) + ], + 'blacklisted' => [ + 'count' => $stats['blacklisted'], + 'percentage' => $this->formatStat($stats['blacklisted'], $domainTotal) + ], + 'total' => [ + 'count' => $stats['total'], + 'percentage' => $this->formatStat($stats['total'], $domainTotal) + ], + ]; + + $count++; + } + + return [ + 'domains' => $result, + 'total' => count($result), + ]; + } + + private function formatStat(int $count, int $total): int|float + { + $percentage = $total > 0 ? ($count / $total) * 100 : 0; + $percentage = round($percentage, 1); + + return ($percentage == floor($percentage)) ? (int) $percentage : $percentage; + } + + /** + * Get top local-parts of email addresses + * + * Returns statistics for the top 25 local-parts of email addresses: + * - Local-part + * - Count and percentage + * + * @param int $limit Maximum number of local-parts to return (default: 25) + * @return array + */ + public function getTopLocalParts(int $limit = 25): array + { + $localParts = []; + + $subscribers = $this->subscriberRepository->findAll(); + + foreach ($subscribers as $subscriber) { + $email = $subscriber->getEmail(); + $atPosition = strpos($email, '@'); + + if ($atPosition !== false) { + $localPart = substr($email, 0, $atPosition); + + if (!isset($localParts[$localPart])) { + $localParts[$localPart] = 0; + } + + $localParts[$localPart]++; + } + } + + arsort($localParts); + + $result = []; + $count = 0; + $totalSubscribers = array_sum($localParts); + foreach ($localParts as $localPart => $subscriberCount) { + if ($count >= $limit) { + break; + } + + $result[] = [ + 'localPart' => $localPart, + 'count' => $subscriberCount, + 'percentage' => $this->formatStat($subscriberCount, $totalSubscribers), + ]; + + $count++; + } + + return [ + 'localParts' => $result, + 'total' => count($result), + ]; + } +} diff --git a/src/Domain/Analytics/Service/Manager/LinkTrackManager.php b/src/Domain/Analytics/Service/Manager/LinkTrackManager.php new file mode 100644 index 00000000..c57f8468 --- /dev/null +++ b/src/Domain/Analytics/Service/Manager/LinkTrackManager.php @@ -0,0 +1,31 @@ +linkTrackRepository = $linkTrackRepository; + } + + /** + * Get link tracks by message ID + * + * @param int $messageId + * @param int $lastId Last seen ID + * @param int|null $limit Max results + * @return LinkTrack[] + */ + public function getLinkTracksByMessageId(int $messageId, int $lastId = 0, ?int $limit = null): array + { + return $this->linkTrackRepository->getByMessageId($messageId, $lastId, $limit); + } +} diff --git a/src/Domain/Analytics/Service/Manager/UserMessageViewManager.php b/src/Domain/Analytics/Service/Manager/UserMessageViewManager.php new file mode 100644 index 00000000..a5744e13 --- /dev/null +++ b/src/Domain/Analytics/Service/Manager/UserMessageViewManager.php @@ -0,0 +1,26 @@ +userMessageViewRepository = $userMessageViewRepository; + } + + /** + * Count views by message ID + */ + public function countViewsByMessageId(int $messageId): int + { + return $this->userMessageViewRepository->countByMessageId($messageId); + } +} diff --git a/src/Domain/Filter/FilterRequestInterface.php b/src/Domain/Common/Model/Filter/FilterRequestInterface.php similarity index 58% rename from src/Domain/Filter/FilterRequestInterface.php rename to src/Domain/Common/Model/Filter/FilterRequestInterface.php index cfe3710a..534978b9 100644 --- a/src/Domain/Filter/FilterRequestInterface.php +++ b/src/Domain/Common/Model/Filter/FilterRequestInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Filter; +namespace PhpList\Core\Domain\Common\Model\Filter; interface FilterRequestInterface { diff --git a/src/Domain/Model/Interfaces/CreationDate.php b/src/Domain/Common/Model/Interfaces/CreationDate.php similarity index 82% rename from src/Domain/Model/Interfaces/CreationDate.php rename to src/Domain/Common/Model/Interfaces/CreationDate.php index 813970f1..0546bad7 100644 --- a/src/Domain/Model/Interfaces/CreationDate.php +++ b/src/Domain/Common/Model/Interfaces/CreationDate.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Interfaces; +namespace PhpList\Core\Domain\Common\Model\Interfaces; use DateTime; -use Doctrine\ORM\Mapping\PrePersist; /** * This interface communicates that a domain model has a creation date. diff --git a/src/Domain/Model/Interfaces/DomainModel.php b/src/Domain/Common/Model/Interfaces/DomainModel.php similarity index 84% rename from src/Domain/Model/Interfaces/DomainModel.php rename to src/Domain/Common/Model/Interfaces/DomainModel.php index 024818c2..5cd75ef2 100644 --- a/src/Domain/Model/Interfaces/DomainModel.php +++ b/src/Domain/Common/Model/Interfaces/DomainModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Interfaces; +namespace PhpList\Core\Domain\Common\Model\Interfaces; /** * This is the interface all domain models (entities) should implement. diff --git a/src/Domain/Model/Interfaces/EmbeddableInterface.php b/src/Domain/Common/Model/Interfaces/EmbeddableInterface.php similarity index 55% rename from src/Domain/Model/Interfaces/EmbeddableInterface.php rename to src/Domain/Common/Model/Interfaces/EmbeddableInterface.php index 92524b7a..64d10007 100644 --- a/src/Domain/Model/Interfaces/EmbeddableInterface.php +++ b/src/Domain/Common/Model/Interfaces/EmbeddableInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Interfaces; +namespace PhpList\Core\Domain\Common\Model\Interfaces; interface EmbeddableInterface { diff --git a/src/Domain/Model/Interfaces/Identity.php b/src/Domain/Common/Model/Interfaces/Identity.php similarity index 83% rename from src/Domain/Model/Interfaces/Identity.php rename to src/Domain/Common/Model/Interfaces/Identity.php index 17f2cdb7..3b70ddcd 100644 --- a/src/Domain/Model/Interfaces/Identity.php +++ b/src/Domain/Common/Model/Interfaces/Identity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Interfaces; +namespace PhpList\Core\Domain\Common\Model\Interfaces; /** * This interface communicates that a domain model has an ID property. diff --git a/src/Domain/Model/Interfaces/ModificationDate.php b/src/Domain/Common/Model/Interfaces/ModificationDate.php similarity index 91% rename from src/Domain/Model/Interfaces/ModificationDate.php rename to src/Domain/Common/Model/Interfaces/ModificationDate.php index ba8ccc50..22432b2f 100644 --- a/src/Domain/Model/Interfaces/ModificationDate.php +++ b/src/Domain/Common/Model/Interfaces/ModificationDate.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Interfaces; +namespace PhpList\Core\Domain\Common\Model\Interfaces; use DateTime; use Doctrine\ORM\Mapping; diff --git a/src/Domain/Common/Model/ValidationContext.php b/src/Domain/Common/Model/ValidationContext.php new file mode 100644 index 00000000..155f7958 --- /dev/null +++ b/src/Domain/Common/Model/ValidationContext.php @@ -0,0 +1,27 @@ +options[$key] = $value; + + return $this; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->options[$key] ?? $default; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->options); + } +} diff --git a/src/Domain/Repository/AbstractRepository.php b/src/Domain/Common/Repository/AbstractRepository.php similarity index 89% rename from src/Domain/Repository/AbstractRepository.php rename to src/Domain/Common/Repository/AbstractRepository.php index 18328e49..284ef544 100644 --- a/src/Domain/Repository/AbstractRepository.php +++ b/src/Domain/Common/Repository/AbstractRepository.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Repository; +namespace PhpList\Core\Domain\Common\Repository; use Doctrine\ORM\EntityRepository; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; /** * Base class for repositories. @@ -15,8 +15,6 @@ */ abstract class AbstractRepository extends EntityRepository { - protected ?string $alias = null; - /** * Persists $model and flushes the entity manager change list. * diff --git a/src/Domain/Repository/CursorPaginationTrait.php b/src/Domain/Common/Repository/CursorPaginationTrait.php similarity index 84% rename from src/Domain/Repository/CursorPaginationTrait.php rename to src/Domain/Common/Repository/CursorPaginationTrait.php index 573435a1..8be64ee2 100644 --- a/src/Domain/Repository/CursorPaginationTrait.php +++ b/src/Domain/Common/Repository/CursorPaginationTrait.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Repository; +namespace PhpList\Core\Domain\Common\Repository; -use PhpList\Core\Domain\Filter\FilterRequestInterface; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use RuntimeException; trait CursorPaginationTrait diff --git a/src/Domain/Repository/Interfaces/PaginatableRepositoryInterface.php b/src/Domain/Common/Repository/Interfaces/PaginatableRepositoryInterface.php similarity index 66% rename from src/Domain/Repository/Interfaces/PaginatableRepositoryInterface.php rename to src/Domain/Common/Repository/Interfaces/PaginatableRepositoryInterface.php index e32706f8..7bacc855 100644 --- a/src/Domain/Repository/Interfaces/PaginatableRepositoryInterface.php +++ b/src/Domain/Common/Repository/Interfaces/PaginatableRepositoryInterface.php @@ -2,9 +2,9 @@ namespace PhpList\Core\Domain\Repository\Interfaces; -namespace PhpList\Core\Domain\Repository\Interfaces; +namespace PhpList\Core\Domain\Common\Repository\Interfaces; -use PhpList\Core\Domain\Filter\FilterRequestInterface; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; interface PaginatableRepositoryInterface { diff --git a/src/Domain/Common/Validator/ValidatorInterface.php b/src/Domain/Common/Validator/ValidatorInterface.php new file mode 100644 index 00000000..71855bc4 --- /dev/null +++ b/src/Domain/Common/Validator/ValidatorInterface.php @@ -0,0 +1,12 @@ +listId = $listId; - return $this; - } - - public function getListId(): ?int - { - return $this->listId; - } -} diff --git a/src/Domain/Identity/Exception/AdminAttributeCreationException.php b/src/Domain/Identity/Exception/AdminAttributeCreationException.php new file mode 100644 index 00000000..3359024a --- /dev/null +++ b/src/Domain/Identity/Exception/AdminAttributeCreationException.php @@ -0,0 +1,23 @@ +statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/Domain/Identity/Exception/AttributeDefinitionCreationException.php b/src/Domain/Identity/Exception/AttributeDefinitionCreationException.php new file mode 100644 index 00000000..5d105893 --- /dev/null +++ b/src/Domain/Identity/Exception/AttributeDefinitionCreationException.php @@ -0,0 +1,23 @@ +statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/Domain/Model/Identity/AdminAttribute.php b/src/Domain/Identity/Model/AdminAttributeDefinition.php similarity index 85% rename from src/Domain/Model/Identity/AdminAttribute.php rename to src/Domain/Identity/Model/AdminAttributeDefinition.php index be740895..b9a70346 100644 --- a/src/Domain/Model/Identity/AdminAttribute.php +++ b/src/Domain/Identity/Model/AdminAttributeDefinition.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Identity; +namespace PhpList\Core\Domain\Identity\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Identity\AdminAttributeRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; -#[ORM\Entity(repositoryClass: AdminAttributeRepository::class)] -#[ORM\Table(name: 'phplist_admin_attribute')] +#[ORM\Entity(repositoryClass: AdminAttributeDefinitionRepository::class)] +#[ORM\Table(name: 'phplist_adminattribute')] #[ORM\HasLifecycleCallbacks] -class AdminAttribute implements DomainModel, Identity +class AdminAttributeDefinition implements DomainModel, Identity { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/src/Domain/Identity/Model/AdminAttributeValue.php b/src/Domain/Identity/Model/AdminAttributeValue.php new file mode 100644 index 00000000..3f4e6c68 --- /dev/null +++ b/src/Domain/Identity/Model/AdminAttributeValue.php @@ -0,0 +1,60 @@ +attributeDefinition = $attributeDefinition; + $this->administrator = $administrator; + $this->value = $value; + } + + public function getAttributeDefinition(): AdminAttributeDefinition + { + return $this->attributeDefinition; + } + + public function getAdministrator(): Administrator + { + return $this->administrator; + } + + public function getValue(): ?string + { + return $this->value; + } + + public function setValue(?string $value): self + { + $this->value = $value; + + return $this; + } +} diff --git a/src/Domain/Model/Identity/AdminLogin.php b/src/Domain/Identity/Model/AdminLogin.php similarity index 90% rename from src/Domain/Model/Identity/AdminLogin.php rename to src/Domain/Identity/Model/AdminLogin.php index a5afaebb..b7c30e58 100644 --- a/src/Domain/Model/Identity/AdminLogin.php +++ b/src/Domain/Identity/Model/AdminLogin.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Identity; +namespace PhpList\Core\Domain\Identity\Model; use DateTimeImmutable; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Identity\AdminLoginRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Identity\Repository\AdminLoginRepository; #[ORM\Entity(repositoryClass: AdminLoginRepository::class)] #[ORM\Table(name: 'phplist_admin_login')] diff --git a/src/Domain/Model/Identity/AdminPasswordRequest.php b/src/Domain/Identity/Model/AdminPasswordRequest.php similarity index 85% rename from src/Domain/Model/Identity/AdminPasswordRequest.php rename to src/Domain/Identity/Model/AdminPasswordRequest.php index b2ae7bcb..c06a45eb 100644 --- a/src/Domain/Model/Identity/AdminPasswordRequest.php +++ b/src/Domain/Identity/Model/AdminPasswordRequest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Identity; +namespace PhpList\Core\Domain\Identity\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Identity\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; #[ORM\Entity(repositoryClass: AdminPasswordRequestRepository::class)] #[ORM\Table(name: 'phplist_admin_password_request')] diff --git a/src/Domain/Model/Identity/Administrator.php b/src/Domain/Identity/Model/Administrator.php similarity index 92% rename from src/Domain/Model/Identity/Administrator.php rename to src/Domain/Identity/Model/Administrator.php index 9509269d..d45e6c0e 100644 --- a/src/Domain/Model/Identity/Administrator.php +++ b/src/Domain/Identity/Model/Administrator.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Identity; +namespace PhpList\Core\Domain\Identity\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\CreationDate; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; -use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; /** * This class represents an administrator who can log to the system, is allowed to administer diff --git a/src/Domain/Model/Identity/AdministratorToken.php b/src/Domain/Identity/Model/AdministratorToken.php similarity index 90% rename from src/Domain/Model/Identity/AdministratorToken.php rename to src/Domain/Identity/Model/AdministratorToken.php index abac6261..41ff75aa 100644 --- a/src/Domain/Model/Identity/AdministratorToken.php +++ b/src/Domain/Identity/Model/AdministratorToken.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Identity; +namespace PhpList\Core\Domain\Identity\Model; use DateTime; use DateTimeZone; use Doctrine\ORM\Mapping as ORM; use Doctrine\Persistence\Proxy; -use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use Symfony\Component\Serializer\Annotation\SerializedName; -use PhpList\Core\Domain\Model\Interfaces\CreationDate; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; /** * This class represents an API authentication token for an administrator. diff --git a/src/Domain/Identity/Model/Dto/AdminAttributeDefinitionDto.php b/src/Domain/Identity/Model/Dto/AdminAttributeDefinitionDto.php new file mode 100644 index 00000000..429faccb --- /dev/null +++ b/src/Domain/Identity/Model/Dto/AdminAttributeDefinitionDto.php @@ -0,0 +1,21 @@ +adminId = $adminId; + return $this; + } + + public function getAdminId(): ?int + { + return $this->adminId; + } +} diff --git a/src/Domain/Model/Identity/UserBlacklist.php b/src/Domain/Identity/Model/UserBlacklist.php similarity index 84% rename from src/Domain/Model/Identity/UserBlacklist.php rename to src/Domain/Identity/Model/UserBlacklist.php index b0b60e9d..1c9d0a30 100644 --- a/src/Domain/Model/Identity/UserBlacklist.php +++ b/src/Domain/Identity/Model/UserBlacklist.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Identity; +namespace PhpList\Core\Domain\Identity\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Identity\UserBlacklistRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Identity\Repository\UserBlacklistRepository; #[ORM\Entity(repositoryClass: UserBlacklistRepository::class)] #[ORM\Table(name: 'phplist_user_blacklist')] diff --git a/src/Domain/Model/Identity/UserBlacklistData.php b/src/Domain/Identity/Model/UserBlacklistData.php similarity index 87% rename from src/Domain/Model/Identity/UserBlacklistData.php rename to src/Domain/Identity/Model/UserBlacklistData.php index bfb2e1e9..09697616 100644 --- a/src/Domain/Model/Identity/UserBlacklistData.php +++ b/src/Domain/Identity/Model/UserBlacklistData.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Identity; +namespace PhpList\Core\Domain\Identity\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Identity\UserBlacklistDataRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Identity\Repository\UserBlacklistDataRepository; #[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)] #[ORM\Table(name: 'phplist_user_blacklist_data')] diff --git a/src/Domain/Identity/Repository/AdminAttributeDefinitionRepository.php b/src/Domain/Identity/Repository/AdminAttributeDefinitionRepository.php new file mode 100644 index 00000000..53df9996 --- /dev/null +++ b/src/Domain/Identity/Repository/AdminAttributeDefinitionRepository.php @@ -0,0 +1,20 @@ +findOneBy(['name' => $name]); + } +} diff --git a/src/Domain/Identity/Repository/AdminAttributeValueRepository.php b/src/Domain/Identity/Repository/AdminAttributeValueRepository.php new file mode 100644 index 00000000..c38b6215 --- /dev/null +++ b/src/Domain/Identity/Repository/AdminAttributeValueRepository.php @@ -0,0 +1,54 @@ +createQueryBuilder('aav') + ->join('aav.administrator', 'admin') + ->join('aav.attributeDefinition', 'attr') + ->where('admin.id = :adminId') + ->andWhere('attr.id = :attributeId') + ->setParameter('adminId', $adminId) + ->setParameter('attributeId', $definitionId) + ->getQuery() + ->getOneOrNullResult(); + } + + + /** + * @return AdminAttributeValue[] + * @throws InvalidArgumentException + */ + public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array + { + if (!$filter instanceof AdminAttributeValueFilter) { + throw new InvalidArgumentException('Expected AdminAttributeValueFilter.'); + } + $query = $this->createQueryBuilder('aav') + ->join('aav.administrator', 'a') + ->join('aav.attributeDefinition', 'ad') + ->where('ad.id > :lastId') + ->setParameter('lastId', $lastId); + + if ($filter->getAdminId() !== null) { + $query->andWhere('a.id = :adminId') + ->setParameter('adminId', $filter->getAdminId()); + } + return $query->orderBy('ad.id', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Domain/Identity/Repository/AdminLoginRepository.php b/src/Domain/Identity/Repository/AdminLoginRepository.php new file mode 100644 index 00000000..051f78aa --- /dev/null +++ b/src/Domain/Identity/Repository/AdminLoginRepository.php @@ -0,0 +1,14 @@ +definitionRepository = $definitionRepository; + } + + public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): AdminAttributeDefinition + { + $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name); + if ($existingAttribute) { + throw new AttributeDefinitionCreationException('Attribute definition already exists', 409); + } + + $attributeDefinition = (new AdminAttributeDefinition($attributeDefinitionDto->name)) + ->setType($attributeDefinitionDto->type) + ->setListOrder($attributeDefinitionDto->listOrder) + ->setRequired($attributeDefinitionDto->required) + ->setDefaultValue($attributeDefinitionDto->defaultValue) + ->setTableName($attributeDefinitionDto->tableName); + + $this->definitionRepository->save($attributeDefinition); + + return $attributeDefinition; + } + + public function update( + AdminAttributeDefinition $attributeDefinition, + AdminAttributeDefinitionDto $attributeDefinitionDto + ): AdminAttributeDefinition { + $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name); + if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) { + throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409); + } + + $attributeDefinition + ->setName($attributeDefinitionDto->name) + ->setType($attributeDefinitionDto->type) + ->setListOrder($attributeDefinitionDto->listOrder) + ->setRequired($attributeDefinitionDto->required) + ->setDefaultValue($attributeDefinitionDto->defaultValue) + ->setTableName($attributeDefinitionDto->tableName); + + $this->definitionRepository->save($attributeDefinition); + + return $attributeDefinition; + } + + public function delete(AdminAttributeDefinition $attributeDefinition): void + { + $this->definitionRepository->remove($attributeDefinition); + } + + public function getTotalCount(): int + { + return $this->definitionRepository->count(); + } + + public function getAttributesAfterId(int $afterId, int $limit): array + { + return $this->definitionRepository->getAfterId($afterId, $limit); + } +} diff --git a/src/Domain/Identity/Service/AdminAttributeManager.php b/src/Domain/Identity/Service/AdminAttributeManager.php new file mode 100644 index 00000000..b5cb13f5 --- /dev/null +++ b/src/Domain/Identity/Service/AdminAttributeManager.php @@ -0,0 +1,59 @@ +attributeRepository = $attributeRepository; + } + + public function createOrUpdate( + Administrator $admin, + AdminAttributeDefinition $definition, + ?string $value = null + ): AdminAttributeValue { + $adminAttribute = $this->attributeRepository->findOneByAdminIdAndAttributeId( + adminId: $admin->getId(), + definitionId: $definition->getId() + ); + + if (!$adminAttribute) { + $adminAttribute = new AdminAttributeValue(attributeDefinition: $definition, administrator: $admin); + } + + $value = $value ?? $definition->getDefaultValue(); + if ($value === null) { + throw new AdminAttributeCreationException('Value is required', 400); + } + + $adminAttribute->setValue($value); + $this->attributeRepository->save($adminAttribute); + + return $adminAttribute; + } + + public function getAdminAttribute(int $adminId, int $attributeDefinitionId): ?AdminAttributeValue + { + return $this->attributeRepository->findOneByAdminIdAndAttributeId( + adminId: $adminId, + definitionId: $attributeDefinitionId + ); + } + + public function delete(AdminAttributeValue $attribute): void + { + $this->attributeRepository->remove($attribute); + } +} diff --git a/src/Domain/Identity/Service/AdministratorManager.php b/src/Domain/Identity/Service/AdministratorManager.php new file mode 100644 index 00000000..41731a14 --- /dev/null +++ b/src/Domain/Identity/Service/AdministratorManager.php @@ -0,0 +1,63 @@ +entityManager = $entityManager; + $this->hashGenerator = $hashGenerator; + } + + public function createAdministrator(CreateAdministratorDto $dto): Administrator + { + $administrator = new Administrator(); + $administrator->setLoginName($dto->loginName); + $administrator->setEmail($dto->email); + $administrator->setSuperUser($dto->isSuperUser); + $hashedPassword = $this->hashGenerator->createPasswordHash($dto->password); + $administrator->setPasswordHash($hashedPassword); + + $this->entityManager->persist($administrator); + $this->entityManager->flush(); + + return $administrator; + } + + public function updateAdministrator(Administrator $administrator, UpdateAdministratorDto $dto): void + { + if ($dto->loginName !== null) { + $administrator->setLoginName($dto->loginName); + } + if ($dto->email !== null) { + $administrator->setEmail($dto->email); + } + if ($dto->superAdmin !== null) { + $administrator->setSuperUser($dto->superAdmin); + } + if ($dto->password !== null) { + $hashedPassword = $this->hashGenerator->createPasswordHash($dto->password); + $administrator->setPasswordHash($hashedPassword); + } + + $this->entityManager->flush(); + } + + public function deleteAdministrator(Administrator $administrator): void + { + $this->entityManager->remove($administrator); + $this->entityManager->flush(); + } +} diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php new file mode 100644 index 00000000..966eecff --- /dev/null +++ b/src/Domain/Identity/Service/SessionManager.php @@ -0,0 +1,45 @@ +tokenRepository = $tokenRepository; + $this->administratorRepository = $administratorRepository; + } + + public function createSession(string $loginName, string $password): AdministratorToken + { + $administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password); + if ($administrator === null) { + throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098); + } + + $token = new AdministratorToken(); + $token->setAdministrator($administrator); + $token->generateExpiry(); + $token->generateKey(); + $this->tokenRepository->save($token); + + return $token; + } + + public function deleteSession(AdministratorToken $token): void + { + $this->tokenRepository->remove($token); + } +} diff --git a/src/Domain/Model/Messaging/Attachment.php b/src/Domain/Messaging/Model/Attachment.php similarity index 91% rename from src/Domain/Model/Messaging/Attachment.php rename to src/Domain/Messaging/Model/Attachment.php index 99e205df..79fd5f95 100644 --- a/src/Domain/Model/Messaging/Attachment.php +++ b/src/Domain/Messaging/Model/Attachment.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\AttachmentRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\AttachmentRepository; #[ORM\Entity(repositoryClass: AttachmentRepository::class)] #[ORM\Table(name: 'phplist_attachment')] diff --git a/src/Domain/Model/Messaging/Bounce.php b/src/Domain/Messaging/Model/Bounce.php similarity index 90% rename from src/Domain/Model/Messaging/Bounce.php rename to src/Domain/Messaging/Model/Bounce.php index 5611f9b5..8d665d72 100644 --- a/src/Domain/Model/Messaging/Bounce.php +++ b/src/Domain/Messaging/Model/Bounce.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; -use Doctrine\ORM\Mapping as ORM; use DateTime; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\BounceRepository; +use Doctrine\ORM\Mapping as ORM; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\BounceRepository; #[ORM\Entity(repositoryClass: BounceRepository::class)] #[ORM\Table(name: 'phplist_bounce')] diff --git a/src/Domain/Model/Messaging/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php similarity index 93% rename from src/Domain/Model/Messaging/BounceRegex.php rename to src/Domain/Messaging/Model/BounceRegex.php index 88da280b..510aaad8 100644 --- a/src/Domain/Model/Messaging/BounceRegex.php +++ b/src/Domain/Messaging/Model/BounceRegex.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\BounceRegexRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository; #[ORM\Entity(repositoryClass: BounceRegexRepository::class)] #[ORM\Table(name: 'phplist_bounceregex')] diff --git a/src/Domain/Model/Messaging/BounceRegexBounce.php b/src/Domain/Messaging/Model/BounceRegexBounce.php similarity index 84% rename from src/Domain/Model/Messaging/BounceRegexBounce.php rename to src/Domain/Messaging/Model/BounceRegexBounce.php index f36a9f31..9dbd3168 100644 --- a/src/Domain/Model/Messaging/BounceRegexBounce.php +++ b/src/Domain/Messaging/Model/BounceRegexBounce.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Messaging\BounceRegexBounceRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Messaging\Repository\BounceRegexBounceRepository; #[ORM\Entity(repositoryClass: BounceRegexBounceRepository::class)] #[ORM\Table(name: 'phplist_bounceregex_bounce')] diff --git a/src/Domain/Messaging/Model/Dto/CreateMessageDto.php b/src/Domain/Messaging/Model/Dto/CreateMessageDto.php new file mode 100644 index 00000000..c74690f3 --- /dev/null +++ b/src/Domain/Messaging/Model/Dto/CreateMessageDto.php @@ -0,0 +1,54 @@ +content; + } + + public function getFormat(): MessageFormatDto + { + return $this->format; + } + + public function getMetadata(): MessageMetadataDto + { + return $this->metadata; + } + + public function getOptions(): MessageOptionsDto + { + return $this->options; + } + + public function getSchedule(): MessageScheduleDto + { + return $this->schedule; + } + + public function getTemplateId(): ?int + { + return $this->templateId; + } +} diff --git a/src/Domain/Messaging/Model/Dto/CreateTemplateDto.php b/src/Domain/Messaging/Model/Dto/CreateTemplateDto.php new file mode 100644 index 00000000..009c475f --- /dev/null +++ b/src/Domain/Messaging/Model/Dto/CreateTemplateDto.php @@ -0,0 +1,22 @@ +user; + } + + public function getExisting(): ?Message + { + return $this->existing; + } +} diff --git a/src/Domain/Messaging/Model/Dto/MessageDtoInterface.php b/src/Domain/Messaging/Model/Dto/MessageDtoInterface.php new file mode 100644 index 00000000..d3349f7d --- /dev/null +++ b/src/Domain/Messaging/Model/Dto/MessageDtoInterface.php @@ -0,0 +1,21 @@ +content; + } + + public function getFormat(): MessageFormatDto + { + return $this->format; + } + + public function getMetadata(): MessageMetadataDto + { + return $this->metadata; + } + + public function getOptions(): MessageOptionsDto + { + return $this->options; + } + + public function getSchedule(): MessageScheduleDto + { + return $this->schedule; + } + + public function getTemplateId(): ?int + { + return $this->templateId; + } +} diff --git a/src/Domain/Filter/MessageFilter.php b/src/Domain/Messaging/Model/Filter/MessageFilter.php similarity index 66% rename from src/Domain/Filter/MessageFilter.php rename to src/Domain/Messaging/Model/Filter/MessageFilter.php index a1ec4736..d230a6a1 100644 --- a/src/Domain/Filter/MessageFilter.php +++ b/src/Domain/Messaging/Model/Filter/MessageFilter.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Filter; +namespace PhpList\Core\Domain\Messaging\Model\Filter; -use PhpList\Core\Domain\Model\Identity\Administrator; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; +use PhpList\Core\Domain\Identity\Model\Administrator; class MessageFilter implements FilterRequestInterface { diff --git a/src/Domain/Model/Messaging/ListMessage.php b/src/Domain/Messaging/Model/ListMessage.php similarity index 86% rename from src/Domain/Model/Messaging/ListMessage.php rename to src/Domain/Messaging/Model/ListMessage.php index 52a4dff0..9123bd46 100644 --- a/src/Domain/Model/Messaging/ListMessage.php +++ b/src/Domain/Messaging/Model/ListMessage.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use DateTime; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; -use PhpList\Core\Domain\Repository\Messaging\ListMessageRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Messaging\Repository\ListMessageRepository; #[ORM\Entity(repositoryClass: ListMessageRepository::class)] #[ORM\Table(name: 'phplist_listmessage')] diff --git a/src/Domain/Model/Messaging/Message.php b/src/Domain/Messaging/Model/Message.php similarity index 86% rename from src/Domain/Model/Messaging/Message.php rename to src/Domain/Messaging/Model/Message.php index 278b2f6e..4031a1ad 100644 --- a/src/Domain/Model/Messaging/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; -use PhpList\Core\Domain\Model\Messaging\Message\MessageContent; -use PhpList\Core\Domain\Model\Messaging\Message\MessageFormat; -use PhpList\Core\Domain\Model\Messaging\Message\MessageMetadata; -use PhpList\Core\Domain\Model\Messaging\Message\MessageOptions; -use PhpList\Core\Domain\Model\Messaging\Message\MessageSchedule; -use PhpList\Core\Domain\Repository\Messaging\MessageRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; +use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; +use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; +use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; #[ORM\Entity(repositoryClass: MessageRepository::class)] #[ORM\Table(name: 'phplist_message')] diff --git a/src/Domain/Model/Messaging/Message/MessageContent.php b/src/Domain/Messaging/Model/Message/MessageContent.php similarity index 93% rename from src/Domain/Model/Messaging/Message/MessageContent.php rename to src/Domain/Messaging/Model/Message/MessageContent.php index b80580a6..df714be9 100644 --- a/src/Domain/Model/Messaging/Message/MessageContent.php +++ b/src/Domain/Messaging/Model/Message/MessageContent.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Model\Message; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\EmbeddableInterface; +use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] class MessageContent implements EmbeddableInterface diff --git a/src/Domain/Model/Messaging/Message/MessageFormat.php b/src/Domain/Messaging/Model/Message/MessageFormat.php similarity index 96% rename from src/Domain/Model/Messaging/Message/MessageFormat.php rename to src/Domain/Messaging/Model/Message/MessageFormat.php index de4836e0..00af6df0 100644 --- a/src/Domain/Model/Messaging/Message/MessageFormat.php +++ b/src/Domain/Messaging/Model/Message/MessageFormat.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Model\Message; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; -use PhpList\Core\Domain\Model\Interfaces\EmbeddableInterface; +use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] class MessageFormat implements EmbeddableInterface diff --git a/src/Domain/Model/Messaging/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php similarity index 95% rename from src/Domain/Model/Messaging/Message/MessageMetadata.php rename to src/Domain/Messaging/Model/Message/MessageMetadata.php index 3e4452a1..123103ff 100644 --- a/src/Domain/Model/Messaging/Message/MessageMetadata.php +++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Model\Message; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\EmbeddableInterface; +use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] class MessageMetadata implements EmbeddableInterface diff --git a/src/Domain/Model/Messaging/Message/MessageOptions.php b/src/Domain/Messaging/Model/Message/MessageOptions.php similarity index 94% rename from src/Domain/Model/Messaging/Message/MessageOptions.php rename to src/Domain/Messaging/Model/Message/MessageOptions.php index 002d3c9a..6a00c95e 100644 --- a/src/Domain/Model/Messaging/Message/MessageOptions.php +++ b/src/Domain/Messaging/Model/Message/MessageOptions.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Model\Message; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\EmbeddableInterface; +use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] class MessageOptions implements EmbeddableInterface diff --git a/src/Domain/Model/Messaging/Message/MessageSchedule.php b/src/Domain/Messaging/Model/Message/MessageSchedule.php similarity index 95% rename from src/Domain/Model/Messaging/Message/MessageSchedule.php rename to src/Domain/Messaging/Model/Message/MessageSchedule.php index 3448f8d1..7c157c1c 100644 --- a/src/Domain/Model/Messaging/Message/MessageSchedule.php +++ b/src/Domain/Messaging/Model/Message/MessageSchedule.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Model\Message; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\EmbeddableInterface; +use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] class MessageSchedule implements EmbeddableInterface diff --git a/src/Domain/Model/Messaging/MessageAttachment.php b/src/Domain/Messaging/Model/MessageAttachment.php similarity index 87% rename from src/Domain/Model/Messaging/MessageAttachment.php rename to src/Domain/Messaging/Model/MessageAttachment.php index 1f65656e..2675790d 100644 --- a/src/Domain/Model/Messaging/MessageAttachment.php +++ b/src/Domain/Messaging/Model/MessageAttachment.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\MessageAttachmentRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\MessageAttachmentRepository; #[ORM\Entity(repositoryClass: MessageAttachmentRepository::class)] #[ORM\Table(name: 'phplist_message_attachment')] diff --git a/src/Domain/Model/Messaging/MessageData.php b/src/Domain/Messaging/Model/MessageData.php similarity index 86% rename from src/Domain/Model/Messaging/MessageData.php rename to src/Domain/Messaging/Model/MessageData.php index 686436f4..56744251 100644 --- a/src/Domain/Model/Messaging/MessageData.php +++ b/src/Domain/Messaging/Model/MessageData.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Messaging\MessageDataRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Messaging\Repository\MessageDataRepository; #[ORM\Entity(repositoryClass: MessageDataRepository::class)] #[ORM\Table(name: 'phplist_messagedata')] diff --git a/src/Domain/Model/Messaging/SendProcess.php b/src/Domain/Messaging/Model/SendProcess.php similarity index 87% rename from src/Domain/Model/Messaging/SendProcess.php rename to src/Domain/Messaging/Model/SendProcess.php index 0c86b941..7b2287d4 100644 --- a/src/Domain/Model/Messaging/SendProcess.php +++ b/src/Domain/Messaging/Model/SendProcess.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; -use PhpList\Core\Domain\Repository\Messaging\SendProcessRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Messaging\Repository\SendProcessRepository; #[ORM\Entity(repositoryClass: SendProcessRepository::class)] #[ORM\Table(name: 'phplist_sendprocess')] diff --git a/src/Domain/Model/Messaging/Template.php b/src/Domain/Messaging/Model/Template.php similarity index 91% rename from src/Domain/Model/Messaging/Template.php rename to src/Domain/Messaging/Model/Template.php index 420ba9bc..f7e3f5d0 100644 --- a/src/Domain/Model/Messaging/Template.php +++ b/src/Domain/Messaging/Model/Template.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; #[ORM\Entity(repositoryClass: TemplateRepository::class)] #[ORM\Table(name: 'phplist_template')] diff --git a/src/Domain/Model/Messaging/TemplateImage.php b/src/Domain/Messaging/Model/TemplateImage.php similarity index 91% rename from src/Domain/Model/Messaging/TemplateImage.php rename to src/Domain/Messaging/Model/TemplateImage.php index 732bba4c..1ef7d7b5 100644 --- a/src/Domain/Model/Messaging/TemplateImage.php +++ b/src/Domain/Messaging/Model/TemplateImage.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\TemplateImageRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository; #[ORM\Entity(repositoryClass: TemplateImageRepository::class)] #[ORM\Table(name: 'phplist_templateimage')] diff --git a/src/Domain/Model/Messaging/UserMessage.php b/src/Domain/Messaging/Model/UserMessage.php similarity index 90% rename from src/Domain/Model/Messaging/UserMessage.php rename to src/Domain/Messaging/Model/UserMessage.php index fbe606fe..9be52903 100644 --- a/src/Domain/Model/Messaging/UserMessage.php +++ b/src/Domain/Messaging/Model/UserMessage.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Repository\Messaging\UserMessageRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; +use PhpList\Core\Domain\Subscription\Model\Subscriber; #[ORM\Entity(repositoryClass: UserMessageRepository::class)] #[ORM\Table(name: 'phplist_usermessage')] diff --git a/src/Domain/Model/Messaging/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php similarity index 88% rename from src/Domain/Model/Messaging/UserMessageBounce.php rename to src/Domain/Messaging/Model/UserMessageBounce.php index 7d9ce4c4..3469fe1b 100644 --- a/src/Domain/Model/Messaging/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\UserMessageBounceRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; #[ORM\Entity(repositoryClass: UserMessageBounceRepository::class)] #[ORM\Table(name: 'phplist_user_message_bounce')] diff --git a/src/Domain/Model/Messaging/UserMessageForward.php b/src/Domain/Messaging/Model/UserMessageForward.php similarity index 90% rename from src/Domain/Model/Messaging/UserMessageForward.php rename to src/Domain/Messaging/Model/UserMessageForward.php index 1f69da23..6dbbcc15 100644 --- a/src/Domain/Model/Messaging/UserMessageForward.php +++ b/src/Domain/Messaging/Model/UserMessageForward.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Messaging; +namespace PhpList\Core\Domain\Messaging\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Messaging\UserMessageForwardRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository; #[ORM\Entity(repositoryClass: UserMessageForwardRepository::class)] #[ORM\Table(name: 'phplist_user_message_forward')] diff --git a/src/Domain/Messaging/Repository/AttachmentRepository.php b/src/Domain/Messaging/Repository/AttachmentRepository.php new file mode 100644 index 00000000..393c8cb1 --- /dev/null +++ b/src/Domain/Messaging/Repository/AttachmentRepository.php @@ -0,0 +1,14 @@ +createQueryBuilder('umb') + ->select('COUNT(umb.id)') + ->where('IDENTITY(umb.message) = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Domain/Messaging/Repository/UserMessageForwardRepository.php b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php new file mode 100644 index 00000000..b0fa5e58 --- /dev/null +++ b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php @@ -0,0 +1,24 @@ +createQueryBuilder('umf') + ->select('COUNT(umf.id)') + ->where('IDENTITY(umf.message) = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Domain/Messaging/Repository/UserMessageRepository.php b/src/Domain/Messaging/Repository/UserMessageRepository.php new file mode 100644 index 00000000..a19c5823 --- /dev/null +++ b/src/Domain/Messaging/Repository/UserMessageRepository.php @@ -0,0 +1,11 @@ +messageFormatBuilder->build($createMessageDto->getFormat()); + $schedule = $this->messageScheduleBuilder->build($createMessageDto->getSchedule()); + $content = $this->messageContentBuilder->build($createMessageDto->getContent()); + $options = $this->messageOptionsBuilder->build($createMessageDto->getOptions()); + $template = null; + if (isset($createMessageDto->templateId)) { + $template = $this->templateRepository->find($createMessageDto->templateId); + } + + if ($context->getExisting()) { + $context->getExisting()->setFormat($format); + $context->getExisting()->setSchedule($schedule); + $context->getExisting()->setContent($content); + $context->getExisting()->setOptions($options); + $context->getExisting()->setTemplate($template); + return $context->getExisting(); + } + + $metadata = new Message\MessageMetadata($createMessageDto->getMetadata()->status); + + return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template); + } +} diff --git a/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php new file mode 100644 index 00000000..1e9e442d --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php @@ -0,0 +1,26 @@ +subject, + $dto->text, + $dto->textMessage, + $dto->footer + ); + } +} diff --git a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php new file mode 100644 index 00000000..7bf9be8b --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php @@ -0,0 +1,25 @@ +htmlFormated, + $dto->sendFormat, + $dto->formatOptions + ); + } +} diff --git a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php new file mode 100644 index 00000000..0a241f0f --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php @@ -0,0 +1,27 @@ +fromField ?? '', + $dto->toField ?? '', + $dto->replyTo ?? '', + $dto->userSelection, + null, + ); + } +} diff --git a/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php new file mode 100644 index 00000000..df847eaf --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php @@ -0,0 +1,28 @@ +repeatInterval, + new DateTime($dto->repeatUntil), + $dto->requeueInterval, + new DateTime($dto->requeueUntil), + new DateTime($dto->embargo) + ); + } +} diff --git a/src/Domain/Messaging/Service/MessageManager.php b/src/Domain/Messaging/Service/MessageManager.php new file mode 100644 index 00000000..9af4df0b --- /dev/null +++ b/src/Domain/Messaging/Service/MessageManager.php @@ -0,0 +1,56 @@ +messageRepository = $messageRepository; + $this->messageBuilder = $messageBuilder; + } + + public function createMessage(MessageDtoInterface $createMessageDto, Administrator $authUser): Message + { + $context = new MessageContext($authUser); + $message = $this->messageBuilder->build($createMessageDto, $context); + $this->messageRepository->save($message); + + return $message; + } + + public function updateMessage( + MessageDtoInterface $updateMessageDto, + Message $message, + Administrator $authUser + ): Message { + $context = new MessageContext($authUser, $message); + $message = $this->messageBuilder->build($updateMessageDto, $context); + $this->messageRepository->save($message); + + return $message; + } + + public function delete(Message $message): void + { + $this->messageRepository->remove($message); + } + + /** @return Message[] */ + public function getMessagesByOwner(Administrator $owner): array + { + return $this->messageRepository->getByOwnerId($owner->getId()); + } +} diff --git a/src/Domain/Messaging/Service/TemplateImageManager.php b/src/Domain/Messaging/Service/TemplateImageManager.php new file mode 100644 index 00000000..c5ebd3f4 --- /dev/null +++ b/src/Domain/Messaging/Service/TemplateImageManager.php @@ -0,0 +1,104 @@ + 'image/gif', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'jpe' => 'image/jpeg', + 'bmp' => 'image/bmp', + 'png' => 'image/png', + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'swf' => 'application/x-shockwave-flash', + ]; + + private TemplateImageRepository $templateImageRepository; + private EntityManagerInterface $entityManager; + + public function __construct( + TemplateImageRepository $templateImageRepository, + EntityManagerInterface $entityManager + ) { + $this->templateImageRepository = $templateImageRepository; + $this->entityManager = $entityManager; + } + + /** @return TemplateImage[] */ + public function createImagesFromImagePaths(array $imagePaths, Template $template): array + { + $templateImages = []; + foreach ($imagePaths as $path) { + $image = new TemplateImage(); + $image->setTemplate($template); + $image->setFilename($path); + $image->setMimeType($this->guessMimeType($path)); + $image->setData(null); + + $this->entityManager->persist($image); + $templateImages[] = $image; + } + + $this->entityManager->flush(); + + return $templateImages; + } + + private function guessMimeType(string $filename): string + { + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + return self::IMAGE_MIME_TYPES[$ext] ?? 'application/octet-stream'; + } + + public function extractAllImages(string $html): array + { + $fromRegex = array_keys( + $this->extractTemplateImagesFromContent($html) + ); + + $fromDom = $this->extractImagesFromHtml($html); + + return array_values(array_unique(array_merge($fromRegex, $fromDom))); + } + + private function extractTemplateImagesFromContent(string $content): array + { + $regexp = sprintf('/"([^"]+\.(%s))"/Ui', implode('|', array_keys(self::IMAGE_MIME_TYPES))); + preg_match_all($regexp, stripslashes($content), $images); + + return array_count_values($images[1]); + } + + private function extractImagesFromHtml(string $html): array + { + $dom = new DOMDocument(); + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + @$dom->loadHTML($html); + $images = []; + + foreach ($dom->getElementsByTagName('img') as $img) { + $src = $img->getAttribute('src'); + if ($src) { + $images[] = $src; + } + } + + return $images; + } + + public function delete(TemplateImage $templateImage): void + { + $this->templateImageRepository->remove($templateImage); + } +} diff --git a/src/Domain/Messaging/Service/TemplateManager.php b/src/Domain/Messaging/Service/TemplateManager.php new file mode 100644 index 00000000..35678484 --- /dev/null +++ b/src/Domain/Messaging/Service/TemplateManager.php @@ -0,0 +1,87 @@ +templateRepository = $templateRepository; + $this->entityManager = $entityManager; + $this->templateImageManager = $templateImageManager; + $this->templateLinkValidator = $templateLinkValidator; + $this->templateImageValidator = $templateImageValidator; + } + + public function create(CreateTemplateDto $createTemplateDto): Template + { + $template = (new Template($createTemplateDto->title)) + ->setContent($createTemplateDto->content) + ->setText($createTemplateDto->text); + + if ($createTemplateDto->fileContent) { + $template->setContent($createTemplateDto->fileContent); + } + + $context = (new ValidationContext()) + ->set('checkLinks', $createTemplateDto->shouldCheckLinks) + ->set('checkImages', $createTemplateDto->shouldCheckImages) + ->set('checkExternalImages', $createTemplateDto->shouldCheckExternalImages); + + $this->templateLinkValidator->validate($template->getContent() ?? '', $context); + + $imageUrls = $this->templateImageManager->extractAllImages($template->getContent() ?? ''); + $this->templateImageValidator->validate($imageUrls, $context); + + $this->templateRepository->save($template); + + $this->templateImageManager->createImagesFromImagePaths($imageUrls, $template); + + return $template; + } + + public function update(UpdateSubscriberDto $updateSubscriberDto): Subscriber + { + /** @var Subscriber $subscriber */ + $subscriber = $this->templateRepository->find($updateSubscriberDto->subscriberId); + + $subscriber->setEmail($updateSubscriberDto->email); + $subscriber->setConfirmed($updateSubscriberDto->confirmed); + $subscriber->setBlacklisted($updateSubscriberDto->blacklisted); + $subscriber->setHtmlEmail($updateSubscriberDto->htmlEmail); + $subscriber->setDisabled($updateSubscriberDto->disabled); + $subscriber->setExtraData($updateSubscriberDto->additionalData); + + $this->entityManager->flush(); + + return $subscriber; + } + + public function delete(Template $template): void + { + $this->templateRepository->remove($template); + } +} diff --git a/src/Domain/Messaging/Validator/TemplateImageValidator.php b/src/Domain/Messaging/Validator/TemplateImageValidator.php new file mode 100644 index 00000000..11bcc329 --- /dev/null +++ b/src/Domain/Messaging/Validator/TemplateImageValidator.php @@ -0,0 +1,73 @@ +get('checkImages', false); + $checkExist = $context?->get('checkExternalImages', false); + + $errors = array_merge( + $checkFull ? $this->validateFullUrls($value) : [], + $checkExist ? $this->validateExistence($value) : [] + ); + + if (!empty($errors)) { + throw new ValidatorException(implode("\n", $errors)); + } + } + + private function validateFullUrls(array $urls): array + { + $errors = []; + + foreach ($urls as $url) { + if (!preg_match('#^https?://#i', $url)) { + $errors[] = sprintf('Image "%s" is not a full URL.', $url); + } + } + + return $errors; + } + + private function validateExistence(array $urls): array + { + $errors = []; + + foreach ($urls as $url) { + if (!preg_match('#^https?://#i', $url)) { + continue; + } + + try { + $response = $this->httpClient->request('HEAD', $url); + if ($response->getStatusCode() !== 200) { + $errors[] = sprintf('Image "%s" does not exist (HTTP %s)', $url, $response->getStatusCode()); + } + } catch (Throwable $e) { + $errors[] = sprintf('Image "%s" could not be validated: %s', $url, $e->getMessage()); + } + } + + return $errors; + } +} diff --git a/src/Domain/Messaging/Validator/TemplateLinkValidator.php b/src/Domain/Messaging/Validator/TemplateLinkValidator.php new file mode 100644 index 00000000..18c772df --- /dev/null +++ b/src/Domain/Messaging/Validator/TemplateLinkValidator.php @@ -0,0 +1,63 @@ +get('checkLinks', false)) { + return; + } + $links = $this->extractLinks($value); + $invalid = []; + + foreach ($links as $link) { + if (!preg_match('#^https?://#i', $link) && + !preg_match('#^mailto:#i', $link) && + !in_array(strtoupper($link), self::PLACEHOLDERS, true) + ) { + $invalid[] = $link; + } + } + + if (!empty($invalid)) { + throw new ValidatorException(sprintf( + 'Not full URLs: %s', + implode(', ', $invalid) + )); + } + } + + private function extractLinks(string $html): array + { + $dom = new DOMDocument(); + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + @$dom->loadHTML($html); + $links = []; + + foreach ($dom->getElementsByTagName('a') as $node) { + $href = $node->getAttribute('href'); + if ($href) { + $links[] = $href; + } + } + + return $links; + } +} diff --git a/src/Domain/Model/Identity/AdminAttributeRelation.php b/src/Domain/Model/Identity/AdminAttributeRelation.php deleted file mode 100644 index 454e8800..00000000 --- a/src/Domain/Model/Identity/AdminAttributeRelation.php +++ /dev/null @@ -1,55 +0,0 @@ - true])] - private int $adminAttributeId; - - #[ORM\Id] - #[ORM\Column(name: 'adminid', type: 'integer', options: ['unsigned' => true])] - private int $adminId; - - #[ORM\Column(name: 'value', type: 'string', length: 255, nullable: true)] - private ?string $value; - - public function __construct(int $adminAttributeId, int $adminId, ?string $value = null) - { - $this->adminAttributeId = $adminAttributeId; - $this->adminId = $adminId; - $this->value = $value; - } - - public function getAdminAttributeId(): int - { - return $this->adminAttributeId; - } - - public function getAdminId(): int - { - return $this->adminId; - } - - public function getValue(): ?string - { - return $this->value; - } - - public function setValue(?string $value): self - { - $this->value = $value; - - return $this; - } -} diff --git a/src/Domain/Repository/Analytics/LinkTrackForwardRepository.php b/src/Domain/Repository/Analytics/LinkTrackForwardRepository.php deleted file mode 100644 index ff0b3c8d..00000000 --- a/src/Domain/Repository/Analytics/LinkTrackForwardRepository.php +++ /dev/null @@ -1,14 +0,0 @@ -statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/Domain/Subscription/Exception/SubscriberAttributeCreationException.php b/src/Domain/Subscription/Exception/SubscriberAttributeCreationException.php new file mode 100644 index 00000000..da2b4f8e --- /dev/null +++ b/src/Domain/Subscription/Exception/SubscriberAttributeCreationException.php @@ -0,0 +1,23 @@ +statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/Domain/Subscription/Exception/SubscriptionCreationException.php b/src/Domain/Subscription/Exception/SubscriptionCreationException.php new file mode 100644 index 00000000..3510801d --- /dev/null +++ b/src/Domain/Subscription/Exception/SubscriptionCreationException.php @@ -0,0 +1,23 @@ +statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/Domain/Subscription/Model/Dto/AttributeDefinitionDto.php b/src/Domain/Subscription/Model/Dto/AttributeDefinitionDto.php new file mode 100644 index 00000000..4907e7c0 --- /dev/null +++ b/src/Domain/Subscription/Model/Dto/AttributeDefinitionDto.php @@ -0,0 +1,21 @@ + */ + public array $extraAttributes = []; + + public function __construct( + string $email, + bool $confirmed, + bool $blacklisted, + bool $htmlEmail, + bool $disabled, + ?string $extraData = null, + array $extraAttributes = [] + ) { + $this->email = $email; + $this->confirmed = $confirmed; + $this->blacklisted = $blacklisted; + $this->htmlEmail = $htmlEmail; + $this->disabled = $disabled; + $this->extraData = $extraData; + $this->extraAttributes = $extraAttributes; + } +} diff --git a/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php b/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php new file mode 100644 index 00000000..1a7285d6 --- /dev/null +++ b/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php @@ -0,0 +1,17 @@ +subscriberId = $subscriberId; + return $this; + } + + public function getSubscriberId(): ?int + { + return $this->subscriberId; + } +} diff --git a/src/Domain/Subscription/Model/Filter/SubscriberFilter.php b/src/Domain/Subscription/Model/Filter/SubscriberFilter.php new file mode 100644 index 00000000..302e34ce --- /dev/null +++ b/src/Domain/Subscription/Model/Filter/SubscriberFilter.php @@ -0,0 +1,80 @@ +listId = $listId; + $this->subscribedDateFrom = $subscribedDateFrom; + $this->subscribedDateTo = $subscribedDateTo; + $this->createdDateFrom = $createdDateFrom; + $this->createdDateTo = $createdDateTo; + $this->updatedDateFrom = $updatedDateFrom; + $this->updatedDateTo = $updatedDateTo; + $this->columns = $columns; + } + + public function getListId(): ?int + { + return $this->listId; + } + + public function getSubscribedDateFrom(): ?DateTimeImmutable + { + return $this->subscribedDateFrom; + } + + public function getSubscribedDateTo(): ?DateTimeImmutable + { + return $this->subscribedDateTo; + } + + public function getCreatedDateFrom(): ?DateTimeImmutable + { + return $this->createdDateFrom; + } + + public function getCreatedDateTo(): ?DateTimeImmutable + { + return $this->createdDateTo; + } + + public function getUpdatedDateFrom(): ?DateTimeImmutable + { + return $this->updatedDateFrom; + } + + public function getUpdatedDateTo(): ?DateTimeImmutable + { + return $this->updatedDateTo; + } + + public function getColumns(): array + { + return $this->columns; + } +} diff --git a/src/Domain/Model/Subscription/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php similarity index 84% rename from src/Domain/Model/Subscription/SubscribePage.php rename to src/Domain/Subscription/Model/SubscribePage.php index bd2d29cf..7ec518b2 100644 --- a/src/Domain/Model/Subscription/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Subscription\SubscriberPageRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)] #[ORM\Table(name: 'phplist_subscribepage')] diff --git a/src/Domain/Model/Subscription/SubscribePageData.php b/src/Domain/Subscription/Model/SubscribePageData.php similarity index 86% rename from src/Domain/Model/Subscription/SubscribePageData.php rename to src/Domain/Subscription/Model/SubscribePageData.php index 7c99db46..7d8dcd4e 100644 --- a/src/Domain/Model/Subscription/SubscribePageData.php +++ b/src/Domain/Subscription/Model/SubscribePageData.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Subscription\SubscriberPageDataRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository; #[ORM\Entity(repositoryClass: SubscriberPageDataRepository::class)] #[ORM\Table(name: 'phplist_subscribepage_data')] diff --git a/src/Domain/Model/Subscription/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php similarity index 90% rename from src/Domain/Model/Subscription/Subscriber.php rename to src/Domain/Subscription/Model/Subscriber.php index f06972ec..d48e5730 100644 --- a/src/Domain/Model/Subscription/Subscriber.php +++ b/src/Domain/Subscription/Model/Subscriber.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use DateTime; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\Core\Domain\Model\Interfaces\CreationDate; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; /** * This class represents subscriber who can subscribe to multiple subscriber lists and can receive email messages from @@ -73,10 +73,10 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat private Collection $subscriptions; /** - * @var Collection + * @var Collection */ #[ORM\OneToMany( - targetEntity: SubscriberAttribute::class, + targetEntity: SubscriberAttributeValue::class, mappedBy: 'subscriber', cascade: ['persist', 'remove'], orphanRemoval: true @@ -267,7 +267,7 @@ public function getAttributes(): Collection return $this->attributes; } - public function addAttribute(SubscriberAttribute $attribute): self + public function addAttribute(SubscriberAttributeValue $attribute): self { if (!$this->attributes->contains($attribute)) { $this->attributes[] = $attribute; @@ -276,7 +276,7 @@ public function addAttribute(SubscriberAttribute $attribute): self return $this; } - public function removeAttribute(SubscriberAttribute $attribute): self + public function removeAttribute(SubscriberAttributeValue $attribute): self { $this->attributes->removeElement($attribute); return $this; diff --git a/src/Domain/Model/Subscription/SubscriberAttributeDefinition.php b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php similarity index 91% rename from src/Domain/Model/Subscription/SubscriberAttributeDefinition.php rename to src/Domain/Subscription/Model/SubscriberAttributeDefinition.php index 9c9f51b4..dc7259d6 100644 --- a/src/Domain/Model/Subscription/SubscriberAttributeDefinition.php +++ b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Subscription\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; #[ORM\Entity(repositoryClass: SubscriberAttributeDefinitionRepository::class)] #[ORM\Table(name: 'phplist_user_attribute')] diff --git a/src/Domain/Model/Subscription/SubscriberAttribute.php b/src/Domain/Subscription/Model/SubscriberAttributeValue.php similarity index 82% rename from src/Domain/Model/Subscription/SubscriberAttribute.php rename to src/Domain/Subscription/Model/SubscriberAttributeValue.php index 72c23b65..05209709 100644 --- a/src/Domain/Model/Subscription/SubscriberAttribute.php +++ b/src/Domain/Subscription/Model/SubscriberAttributeValue.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Repository\Subscription\SubscriberAttributeRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; -#[ORM\Entity(repositoryClass: SubscriberAttributeRepository::class)] +#[ORM\Entity(repositoryClass: SubscriberAttributeValueRepository::class)] #[ORM\Table(name: 'phplist_user_user_attribute')] #[ORM\Index(name: 'attindex', columns: ['attributeid'])] #[ORM\Index(name: 'attuserid', columns: ['userid', 'attributeid'])] #[ORM\Index(name: 'userindex', columns: ['userid'])] -class SubscriberAttribute implements DomainModel +class SubscriberAttributeValue implements DomainModel { #[ORM\Id] #[ORM\ManyToOne(targetEntity: SubscriberAttributeDefinition::class)] diff --git a/src/Domain/Model/Subscription/SubscriberHistory.php b/src/Domain/Subscription/Model/SubscriberHistory.php similarity index 91% rename from src/Domain/Model/Subscription/SubscriberHistory.php rename to src/Domain/Subscription/Model/SubscriberHistory.php index 5761a96a..0eaa3f7a 100644 --- a/src/Domain/Model/Subscription/SubscriberHistory.php +++ b/src/Domain/Subscription/Model/SubscriberHistory.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Repository\Subscription\SubscriberHistoryRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; #[ORM\Entity(repositoryClass: SubscriberHistoryRepository::class)] #[ORM\Table(name: 'phplist_user_user_history')] diff --git a/src/Domain/Model/Subscription/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php similarity index 92% rename from src/Domain/Model/Subscription/SubscriberList.php rename to src/Domain/Subscription/Model/SubscriberList.php index 1b58875e..65f7a9fa 100644 --- a/src/Domain/Model/Subscription/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use DateTime; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Interfaces\CreationDate; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; -use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use Symfony\Component\Serializer\Attribute\MaxDepth; /** diff --git a/src/Domain/Model/Subscription/Subscription.php b/src/Domain/Subscription/Model/Subscription.php similarity index 90% rename from src/Domain/Model/Subscription/Subscription.php rename to src/Domain/Subscription/Model/Subscription.php index ba1ade79..94e79965 100644 --- a/src/Domain/Model/Subscription/Subscription.php +++ b/src/Domain/Subscription/Model/Subscription.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Model\Subscription; +namespace PhpList\Core\Domain\Subscription\Model; use DateTime; use Doctrine\ORM\Mapping as ORM; use Doctrine\Persistence\Proxy; -use PhpList\Core\Domain\Model\Interfaces\CreationDate; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; -use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository; +use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; use Symfony\Component\Serializer\Annotation\Ignore; use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Serializer\Attribute\Groups; diff --git a/src/Domain/Subscription/Repository/SubscriberAttributeDefinitionRepository.php b/src/Domain/Subscription/Repository/SubscriberAttributeDefinitionRepository.php new file mode 100644 index 00000000..1db2e0e6 --- /dev/null +++ b/src/Domain/Subscription/Repository/SubscriberAttributeDefinitionRepository.php @@ -0,0 +1,20 @@ +findOneBy(['name' => $name]); + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php new file mode 100644 index 00000000..d29d56ff --- /dev/null +++ b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php @@ -0,0 +1,67 @@ +findOneBy([ + 'subscriber' => $subscriber, + 'attributeDefinition' => $attributeDefinition, + ]); + } + + public function findOneBySubscriberIdAndAttributeId( + int $subscriberId, + int $attributeDefinitionId + ): ?SubscriberAttributeValue { + return $this->createQueryBuilder('sa') + ->join('sa.subscriber', 's') + ->join('sa.attributeDefinition', 'ad') + ->where('s.id = :subscriberId') + ->andWhere('ad.id = :attributeDefinitionId') + ->setParameter('subscriberId', $subscriberId) + ->setParameter('attributeDefinitionId', $attributeDefinitionId) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * @return SubscriberAttributeValue[] + * @throws InvalidArgumentException + */ + public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array + { + if (!$filter instanceof SubscriberAttributeValueFilter) { + throw new InvalidArgumentException('Expected SubscriberAttributeValueFilter.'); + } + $query = $this->createQueryBuilder('sa') + ->join('sa.subscriber', 's') + ->join('sa.attributeDefinition', 'ad') + ->where('ad.id > :lastId') + ->setParameter('lastId', $lastId); + + if ($filter->getSubscriberId() !== null) { + $query->andWhere('s.id = :subscriberId') + ->setParameter('subscriberId', $filter->getSubscriberId()); + } + return $query->orderBy('ad.id', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberHistoryRepository.php b/src/Domain/Subscription/Repository/SubscriberHistoryRepository.php new file mode 100644 index 00000000..a9f58ae5 --- /dev/null +++ b/src/Domain/Subscription/Repository/SubscriberHistoryRepository.php @@ -0,0 +1,14 @@ + * @author Tatevik Grigoryan */ class SubscriberRepository extends AbstractRepository implements PaginatableRepositoryInterface { + public function findOneByEmail(string $email): ?Subscriber + { + return $this->findOneBy(['email' => $email]); + } + public function findSubscribersBySubscribedList(int $listId): ?Subscriber { return $this->createQueryBuilder('s') @@ -54,18 +57,42 @@ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterf throw new InvalidArgumentException('Expected SubscriberFilterRequest.'); } - $queryBuilder = $this->createQueryBuilder('s') - ->innerJoin('s.subscriptions', 'subscription') + $queryBuilder = $this->createQueryBuilder('subscriber') + ->innerJoin('subscriber.subscriptions', 'subscription') ->innerJoin('subscription.subscriberList', 'list'); if ($filter->getListId() !== null) { $queryBuilder->where('list.id = :listId') ->setParameter('listId', $filter->getListId()); + if ($filter->getSubscribedDateFrom() !== null) { + $queryBuilder->where('subscription.createdAt > :subscribedAtFrom') + ->setParameter('subscribedAtFrom', $filter->getSubscribedDateFrom()); + } + if ($filter->getSubscribedDateTo() !== null) { + $queryBuilder->where('subscription.createdAt < :subscribedAtTo') + ->setParameter('subscribedAtTo', $filter->getSubscribedDateTo()); + } + } + if ($filter->getCreatedDateFrom() !== null) { + $queryBuilder->where('subscriber.createdAt > :createdAtFrom') + ->setParameter('createdAtFrom', $filter->getCreatedDateFrom()); + } + if ($filter->getCreatedDateTo() !== null) { + $queryBuilder->where('subscriber.createdAt < :createdAtTo') + ->setParameter('createdAtTo', $filter->getCreatedDateTo()); + } + if ($filter->getUpdatedDateFrom() !== null) { + $queryBuilder->where('subscriber.updatedAt > :updatedAtFrom') + ->setParameter('updatedAtFrom', $filter->getUpdatedDateFrom()); + } + if ($filter->getUpdatedDateTo() !== null) { + $queryBuilder->where('subscriber.updatedAt < :updatedAtTo') + ->setParameter('updatedAtTo', $filter->getUpdatedDateTo()); } - return $queryBuilder->andWhere('s.id > :lastId') + return $queryBuilder->andWhere('subscriber.id > :lastId') ->setParameter('lastId', $lastId) - ->orderBy('s.id', 'ASC') + ->orderBy('subscriber.id', 'ASC') ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Domain/Repository/Subscription/SubscriptionRepository.php b/src/Domain/Subscription/Repository/SubscriptionRepository.php similarity index 80% rename from src/Domain/Repository/Subscription/SubscriptionRepository.php rename to src/Domain/Subscription/Repository/SubscriptionRepository.php index e1e97827..de2bea56 100644 --- a/src/Domain/Repository/Subscription/SubscriptionRepository.php +++ b/src/Domain/Subscription/Repository/SubscriptionRepository.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Repository\Subscription; +namespace PhpList\Core\Domain\Subscription\Repository; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Model\Subscription\Subscription; -use PhpList\Core\Domain\Repository\AbstractRepository; +use PhpList\Core\Domain\Common\Repository\AbstractRepository; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Model\Subscription; /** * Repository for Subscription models. diff --git a/src/Domain/Subscription/Service/CsvImporter.php b/src/Domain/Subscription/Service/CsvImporter.php new file mode 100644 index 00000000..3b3729e3 --- /dev/null +++ b/src/Domain/Subscription/Service/CsvImporter.php @@ -0,0 +1,58 @@ +>} + * @throws CsvException + */ + public function import(string $csvFilePath): array + { + $reader = Reader::createFromPath($csvFilePath, 'r'); + $reader->setHeaderOffset(0); + $records = $reader->getRecords(); + $validDtos = []; + $errors = []; + + foreach ($records as $index => $row) { + try { + $dto = $this->rowMapper->map($row); + $violations = $this->validator->validate($dto); + + if (count($violations) > 0) { + $errors[$index + 1] = []; + foreach ($violations as $violation) { + $errors[$index + 1][] = $violation->getPropertyPath() . ': ' . $violation->getMessage(); + } + continue; + } + + $validDtos[] = $dto; + } catch (Throwable $e) { + $errors[$index + 1][] = 'Unexpected error: ' . $e->getMessage(); + } + } + + return [ + 'valid' => $validDtos, + 'errors' => $errors, + ]; + } +} diff --git a/src/Domain/Subscription/Service/CsvRowToDtoMapper.php b/src/Domain/Subscription/Service/CsvRowToDtoMapper.php new file mode 100644 index 00000000..606e58d8 --- /dev/null +++ b/src/Domain/Subscription/Service/CsvRowToDtoMapper.php @@ -0,0 +1,31 @@ +definitionRepository = $definitionRepository; + } + + public function create(AttributeDefinitionDto $attributeDefinitionDto): SubscriberAttributeDefinition + { + $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name); + if ($existingAttribute) { + throw new AttributeDefinitionCreationException('Attribute definition already exists', 409); + } + + $attributeDefinition = (new SubscriberAttributeDefinition()) + ->setName($attributeDefinitionDto->name) + ->setType($attributeDefinitionDto->type) + ->setListOrder($attributeDefinitionDto->listOrder) + ->setRequired($attributeDefinitionDto->required) + ->setDefaultValue($attributeDefinitionDto->defaultValue) + ->setTableName($attributeDefinitionDto->tableName); + + $this->definitionRepository->save($attributeDefinition); + + return $attributeDefinition; + } + + public function update( + SubscriberAttributeDefinition $attributeDefinition, + AttributeDefinitionDto $attributeDefinitionDto + ): SubscriberAttributeDefinition { + $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name); + if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) { + throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409); + } + + $attributeDefinition + ->setName($attributeDefinitionDto->name) + ->setType($attributeDefinitionDto->type) + ->setListOrder($attributeDefinitionDto->listOrder) + ->setRequired($attributeDefinitionDto->required) + ->setDefaultValue($attributeDefinitionDto->defaultValue) + ->setTableName($attributeDefinitionDto->tableName); + + $this->definitionRepository->save($attributeDefinition); + + return $attributeDefinition; + } + + public function delete(SubscriberAttributeDefinition $attributeDefinition): void + { + $this->definitionRepository->remove($attributeDefinition); + } + + public function getTotalCount(): int + { + return $this->definitionRepository->count(); + } + + public function getAttributesAfterId(int $afterId, int $limit): array + { + return $this->definitionRepository->getAfterId($afterId, $limit); + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php new file mode 100644 index 00000000..48b8a7ed --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php @@ -0,0 +1,54 @@ +attributeRepository = $attributeRepository; + } + + public function createOrUpdate( + Subscriber $subscriber, + SubscriberAttributeDefinition $definition, + ?string $value = null + ): SubscriberAttributeValue { + $subscriberAttribute = $this->attributeRepository + ->findOneBySubscriberAndAttribute($subscriber, $definition); + + if (!$subscriberAttribute) { + $subscriberAttribute = new SubscriberAttributeValue($definition, $subscriber); + } + + $value = $value ?? $definition->getDefaultValue(); + if ($value === null) { + throw new SubscriberAttributeCreationException('Value is required', 400); + } + + $subscriberAttribute->setValue($value); + $this->attributeRepository->save($subscriberAttribute); + + return $subscriberAttribute; + } + + public function getSubscriberAttribute(int $subscriberId, int $attributeDefinitionId): ?SubscriberAttributeValue + { + return $this->attributeRepository->findOneBySubscriberIdAndAttributeId($subscriberId, $attributeDefinitionId); + } + + public function delete(SubscriberAttributeValue $attribute): void + { + $this->attributeRepository->remove($attribute); + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriberListManager.php b/src/Domain/Subscription/Service/Manager/SubscriberListManager.php new file mode 100644 index 00000000..a56b48eb --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscriberListManager.php @@ -0,0 +1,54 @@ +subscriberListRepository = $subscriberListRepository; + } + + public function createSubscriberList( + CreateSubscriberListDto $subscriberListDto, + Administrator $authUser + ): SubscriberList { + $subscriberList = (new SubscriberList()) + ->setName($subscriberListDto->name) + ->setOwner($authUser) + ->setDescription($subscriberListDto->description) + ->setListPosition($subscriberListDto->listPosition) + ->setPublic($subscriberListDto->isPublic); + + $this->subscriberListRepository->save($subscriberList); + + return $subscriberList; + } + + /** + * @return SubscriberList[] + */ + public function getPaginated(int $afterId, int $limit): array + { + return $this->subscriberListRepository->getAfterId($afterId, $limit); + } + + public function getTotalCount(): int + { + return $this->subscriberListRepository->count(); + } + + public function delete(SubscriberList $subscriberList): void + { + $this->subscriberListRepository->remove($subscriberList); + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php new file mode 100644 index 00000000..92273278 --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -0,0 +1,100 @@ +subscriberRepository = $subscriberRepository; + $this->entityManager = $entityManager; + } + + public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber + { + $subscriber = new Subscriber(); + $subscriber->setEmail($subscriberDto->email); + $confirmed = (bool)$subscriberDto->requestConfirmation; + $subscriber->setConfirmed(!$confirmed); + $subscriber->setBlacklisted(false); + $subscriber->setHtmlEmail((bool)$subscriberDto->htmlEmail); + $subscriber->setDisabled(false); + + $this->subscriberRepository->save($subscriber); + + return $subscriber; + } + + public function getSubscriber(int $subscriberId): Subscriber + { + $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); + + if (!$subscriber) { + throw new NotFoundHttpException('Subscriber not found'); + } + + return $subscriber; + } + + public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber + { + /** @var Subscriber $subscriber */ + $subscriber = $this->subscriberRepository->find($subscriberDto->subscriberId); + + $subscriber->setEmail($subscriberDto->email); + $subscriber->setConfirmed($subscriberDto->confirmed); + $subscriber->setBlacklisted($subscriberDto->blacklisted); + $subscriber->setHtmlEmail($subscriberDto->htmlEmail); + $subscriber->setDisabled($subscriberDto->disabled); + $subscriber->setExtraData($subscriberDto->additionalData); + + $this->entityManager->flush(); + + return $subscriber; + } + + public function deleteSubscriber(Subscriber $subscriber): void + { + $this->subscriberRepository->remove($subscriber); + } + + public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber + { + $subscriber = new Subscriber(); + $subscriber->setEmail($subscriberDto->email); + $subscriber->setConfirmed($subscriberDto->confirmed); + $subscriber->setBlacklisted($subscriberDto->blacklisted); + $subscriber->setHtmlEmail($subscriberDto->htmlEmail); + $subscriber->setDisabled($subscriberDto->disabled); + $subscriber->setExtraData($subscriberDto->extraData); + + $this->subscriberRepository->save($subscriber); + + return $subscriber; + } + + public function updateFromImport(Subscriber $existingSubscriber, ImportSubscriberDto $subscriberDto): Subscriber + { + $existingSubscriber->setEmail($subscriberDto->email); + $existingSubscriber->setConfirmed($subscriberDto->confirmed); + $existingSubscriber->setBlacklisted($subscriberDto->blacklisted); + $existingSubscriber->setHtmlEmail($subscriberDto->htmlEmail); + $existingSubscriber->setDisabled($subscriberDto->disabled); + $existingSubscriber->setExtraData($subscriberDto->extraData); + + return $existingSubscriber; + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php new file mode 100644 index 00000000..bb3a0e14 --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php @@ -0,0 +1,115 @@ +subscriptionRepository = $subscriptionRepository; + $this->subscriberRepository = $subscriberRepository; + $this->subscriberListRepository = $subscriberListRepository; + } + + public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription + { + $existingSubscription = $this->subscriptionRepository + ->findOneBySubscriberEmailAndListId($listId, $subscriber->getEmail()); + if ($existingSubscription) { + return $existingSubscription; + } + $subscriberList = $this->subscriberListRepository->find($listId); + if (!$subscriberList) { + throw new SubscriptionCreationException('Subscriber list not found.', 404); + } + + $subscription = new Subscription(); + $subscription->setSubscriber($subscriber); + $subscription->setSubscriberList($subscriberList); + + $this->subscriptionRepository->save($subscription); + + return $subscription; + } + + /** @return Subscription[] */ + public function createSubscriptions(SubscriberList $subscriberList, array $emails): array + { + $subscriptions = []; + foreach ($emails as $email) { + $subscriptions[] = $this->createSubscription($subscriberList, $email); + } + + return $subscriptions; + } + + private function createSubscription(SubscriberList $subscriberList, string $email): Subscription + { + $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); + if (!$subscriber) { + throw new SubscriptionCreationException('Subscriber does not exists.', 404); + } + + $existingSubscription = $this->subscriptionRepository + ->findOneBySubscriberListAndSubscriber($subscriberList, $subscriber); + if ($existingSubscription) { + return $existingSubscription; + } + + $subscription = new Subscription(); + $subscription->setSubscriber($subscriber); + $subscription->setSubscriberList($subscriberList); + + $this->subscriptionRepository->save($subscription); + + return $subscription; + } + + public function deleteSubscriptions(SubscriberList $subscriberList, array $emails): void + { + foreach ($emails as $email) { + try { + $this->deleteSubscription($subscriberList, $email); + } catch (SubscriptionCreationException $e) { + if ($e->getStatusCode() !== 404) { + throw $e; + } + } + } + } + + private function deleteSubscription(SubscriberList $subscriberList, string $email): void + { + $subscription = $this->subscriptionRepository + ->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email); + + if (!$subscription) { + throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404); + } + + $this->subscriptionRepository->remove($subscription); + } + + /** @return Subscriber[] */ + public function getSubscriberListMembers(SubscriberList $list): array + { + return $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); + } +} diff --git a/src/Domain/Subscription/Service/SubscriberCsvExporter.php b/src/Domain/Subscription/Service/SubscriberCsvExporter.php new file mode 100644 index 00000000..33aebb38 --- /dev/null +++ b/src/Domain/Subscription/Service/SubscriberCsvExporter.php @@ -0,0 +1,197 @@ +attributeManager = $attributeManager; + $this->subscriberRepository = $subscriberRepository; + $this->definitionRepository = $definitionRepository; + } + + /** + * Export subscribers to a CSV file. + * + * @param SubscriberFilter|null $filter Optional filter to apply + * @param int $batchSize Number of subscribers to process in each batch + * @return Response A response with the CSV file for download + */ + public function exportToCsv(?SubscriberFilter $filter = null, int $batchSize = 1000): Response + { + if ($filter === null) { + $filter = new SubscriberFilter(); + } + + $tempFilePath = tempnam(sys_get_temp_dir(), 'subscribers_export_'); + $this->generateCsvContent($filter, $batchSize, $tempFilePath, $filter->getColumns()); + + $response = new BinaryFileResponse($tempFilePath); + + return $this->configureResponse($response); + } + + /** + * Generate CSV content for the export. + * + * @param SubscriberFilter $filter Filter to apply + * @param int $batchSize Batch size for processing + * @param string $filePath Path to the file where CSV content will be written + */ + private function generateCsvContent( + SubscriberFilter $filter, + int $batchSize, + string $filePath, + array $columns + ): void { + $handle = fopen($filePath, 'w'); + /** @var SubscriberAttributeDefinition[] $attributeDefinitions */ + $attributeDefinitions = $this->definitionRepository->findAll(); + + $headers = $this->getExportHeaders($attributeDefinitions, $columns); + fputcsv($handle, $headers); + + $this->exportSubscribers($handle, $filter, $batchSize, $attributeDefinitions, $headers); + + fclose($handle); + } + + /** + * Get headers for the export CSV. + * + * @param SubscriberAttributeDefinition[] $attributeDefinitions Attribute definitions + * @return array Headers + */ + private function getExportHeaders(array $attributeDefinitions, array $columns): array + { + $headers = [ + 'email', + 'confirmed', + 'blacklisted', + 'htmlEmail', + 'disabled', + 'extraData', + ]; + + foreach ($attributeDefinitions as $definition) { + $headers[] = $definition->getName(); + } + + $headers = array_filter($headers, fn($header) => in_array($header, $columns, true)); + + return array_values($headers); + } + + /** + * Export subscribers in batches. + * + * @param resource $handle File handle + * @param SubscriberFilter $filter Filter to apply + * @param int $batchSize Batch size + * @param SubscriberAttributeDefinition[] $attributeDefinitions Attribute definitions + */ + private function exportSubscribers( + $handle, + SubscriberFilter $filter, + int $batchSize, + array $attributeDefinitions, + array $headers + ): void { + $lastId = 0; + + do { + $subscribers = $this->subscriberRepository->getFilteredAfterId( + lastId: $lastId, + limit: $batchSize, + filter: $filter + ); + + foreach ($subscribers as $subscriber) { + $row = $this->getSubscriberRow($subscriber, $attributeDefinitions, $headers); + fputcsv($handle, $row); + $lastId = $subscriber->getId(); + } + + $subscriberCount = count($subscribers); + } while ($subscriberCount === $batchSize); + } + + /** + * Get a row of data for a subscriber. + * + * @param Subscriber $subscriber The subscriber + * @param SubscriberAttributeDefinition[] $attributeDefinitions Attribute definitions + * @return array Row data + */ + private function getSubscriberRow(Subscriber $subscriber, array $attributeDefinitions, array $headers): array + { + $row = [ + 'id' => $subscriber->getId(), + 'email' => $subscriber->getEmail(), + 'confirmed' => $subscriber->isConfirmed() ? '1' : '0', + 'blacklisted' => $subscriber->isBlacklisted() ? '1' : '0', + 'htmlEmail' => $subscriber->hasHtmlEmail() ? '1' : '0', + 'disabled' => $subscriber->isDisabled() ? '1' : '0', + 'extraData' => $subscriber->getExtraData(), + ]; + + foreach ($attributeDefinitions as $definition) { + $attributeValue = $this->attributeManager->getSubscriberAttribute( + subscriberId: $subscriber->getId(), + attributeDefinitionId: $definition->getId() + ); + $row[$definition->getName()] = $attributeValue ? $attributeValue->getValue() : ''; + } + + $row = array_intersect_key($row, array_flip($headers)); + + $filteredRow = []; + foreach ($headers as $key) { + $filteredRow[] = $row[$key] ?? ''; + } + + return $filteredRow; + } + + /** + * Configure the response for CSV download. + * + * @param BinaryFileResponse $response The response + * @return Response The configured response + */ + private function configureResponse(BinaryFileResponse $response): Response + { + $response->headers->set('Content-Type', 'text/csv; charset=utf-8'); + $disposition = $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + 'subscribers_export_' . date('Y-m-d') . '.csv' + ); + $response->headers->set('Content-Disposition', $disposition); + $response->deleteFileAfterSend(); + + return $response; + } +} diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php new file mode 100644 index 00000000..e84ac5cd --- /dev/null +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -0,0 +1,168 @@ +subscriberManager = $subscriberManager; + $this->attributeManager = $attributeManager; + $this->subscriptionManager = $subscriptionManager; + $this->subscriberRepository = $subscriberRepository; + $this->csvImporter = $csvImporter; + $this->attrDefinitionRepository = $attrDefinitionRepository; + } + + /** + * Import subscribers from a CSV file. + * + * @param UploadedFile $file The uploaded CSV file + * @param SubscriberImportOptions $options + * @return array Import statistics + * @throws RuntimeException When the uploaded file cannot be read or for any other errors during import + */ + public function importFromCsv(UploadedFile $file, SubscriberImportOptions $options): array + { + $stats = [ + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'errors' => [], + ]; + + try { + $path = $file->getRealPath(); + if ($path === false) { + throw new RuntimeException('Could not read the uploaded file.'); + } + + $result = $this->csvImporter->import($path); + + foreach ($result['valid'] as $dto) { + try { + $this->processRow($dto, $options, $stats); + } catch (Throwable $e) { + $stats['errors'][] = 'Error processing ' . $dto->email . ': ' . $e->getMessage(); + $stats['skipped']++; + } + } + + foreach ($result['errors'] as $line => $messages) { + $stats['errors'][] = 'Line ' . $line . ': ' . implode('; ', $messages); + $stats['skipped']++; + } + } catch (Throwable $e) { + $stats['errors'][] = 'General import error: ' . $e->getMessage(); + } + + return $stats; + } + + /** + * Import subscribers with an update strategy. + * + * @param UploadedFile $file The uploaded CSV file + * @return array Import statistics + */ + public function importAndUpdateFromCsv(UploadedFile $file, ?array $listIds = []): array + { + return $this->importFromCsv($file, new SubscriberImportOptions(updateExisting: true, listIds: $listIds)); + } + + /** + * Import subscribers without updating existing ones. + * + * @param UploadedFile $file The uploaded CSV file + * @return array Import statistics + */ + public function importNewFromCsv(UploadedFile $file, ?array $listIds = []): array + { + return $this->importFromCsv($file, new SubscriberImportOptions(listIds: $listIds)); + } + + /** + * Process a single row from the CSV file. + * + * @param ImportSubscriberDto $dto + * @param SubscriberImportOptions $options + * @param array $stats Statistics to update + */ + private function processRow( + ImportSubscriberDto $dto, + SubscriberImportOptions $options, + array &$stats, + ): void { + $subscriber = $this->subscriberRepository->findOneByEmail($dto->email); + + if ($subscriber && !$options->updateExisting) { + $stats['skipped']++; + return; + } + if ($subscriber) { + $this->subscriberManager->updateFromImport($subscriber, $dto); + $stats['updated']++; + } else { + $subscriber = $this->subscriberManager->createFromImport($dto); + $stats['created']++; + } + + $this->processAttributes($subscriber, $dto); + + if (count($options->listIds) > 0) { + foreach ($options->listIds as $listId) { + $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); + } + } + } + + /** + * Process subscriber attributes. + * + * @param Subscriber $subscriber The subscriber + * @param ImportSubscriberDto $dto + */ + private function processAttributes(Subscriber $subscriber, ImportSubscriberDto $dto): void + { + foreach ($dto->extraAttributes as $key => $value) { + $attributeDefinition = $this->attrDefinitionRepository->findOneByName($key); + if ($attributeDefinition !== null) { + $this->attributeManager->createOrUpdate( + $subscriber, + $attributeDefinition, + $value + ); + } + } + } +} diff --git a/src/Security/Authentication.php b/src/Security/Authentication.php index 477476fc..5c6d69c4 100644 --- a/src/Security/Authentication.php +++ b/src/Security/Authentication.php @@ -5,8 +5,8 @@ namespace PhpList\Core\Security; use Doctrine\ORM\EntityNotFoundException; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; +use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; +use PhpList\Core\Domain\Identity\Model\Administrator; use Symfony\Component\HttpFoundation\Request; /** diff --git a/src/TestingSupport/Traits/ModelTestTrait.php b/src/TestingSupport/Traits/ModelTestTrait.php index 5dcfc5d5..10ccbae8 100644 --- a/src/TestingSupport/Traits/ModelTestTrait.php +++ b/src/TestingSupport/Traits/ModelTestTrait.php @@ -4,7 +4,7 @@ namespace PhpList\Core\TestingSupport\Traits; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use ReflectionObject; /** diff --git a/tests/Integration/Core/ConfigProviderTest.php b/tests/Integration/Core/ConfigProviderTest.php new file mode 100644 index 00000000..d2fdf896 --- /dev/null +++ b/tests/Integration/Core/ConfigProviderTest.php @@ -0,0 +1,40 @@ + 'phpList', + 'debug' => true, + ]); + + $this->assertSame('phpList', $provider->get('site_name')); + $this->assertTrue($provider->get('debug')); + } + + public function testReturnsDefaultIfKeyMissing(): void + { + $provider = new ConfigProvider([ + 'site_name' => 'phpList', + ]); + + $this->assertNull($provider->get('nonexistent')); + $this->assertSame('default', $provider->get('nonexistent', 'default')); + } + + public function testReturnsAllConfig(): void + { + $data = ['a' => 1, 'b' => 2]; + $provider = new ConfigProvider($data); + + $this->assertSame($data, $provider->all()); + } +} diff --git a/tests/Integration/Domain/Repository/Fixtures/Identity/Administrator.csv b/tests/Integration/Domain/Identity/Fixtures/Administrator.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Identity/Administrator.csv rename to tests/Integration/Domain/Identity/Fixtures/Administrator.csv diff --git a/tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorFixture.php b/tests/Integration/Domain/Identity/Fixtures/AdministratorFixture.php similarity index 92% rename from tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorFixture.php rename to tests/Integration/Domain/Identity/Fixtures/AdministratorFixture.php index 963d7648..cde421e6 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorFixture.php +++ b/tests/Integration/Domain/Identity/Fixtures/AdministratorFixture.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity; +namespace PhpList\Core\Tests\Integration\Domain\Identity\Fixtures; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Identity\Administrator; +use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorTokenWithAdministrator.csv b/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministrator.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorTokenWithAdministrator.csv rename to tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministrator.csv diff --git a/tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorTokenWithAdministratorFixture.php b/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php similarity index 90% rename from tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorTokenWithAdministratorFixture.php rename to tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php index 9805caea..e382a4c7 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Identity/AdministratorTokenWithAdministratorFixture.php +++ b/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity; +namespace PhpList\Core\Tests\Integration\Domain\Identity\Fixtures; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Identity\AdministratorToken; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Fixtures/Identity/DetachedAdministratorTokenFixture.php b/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php similarity index 91% rename from tests/Integration/Domain/Repository/Fixtures/Identity/DetachedAdministratorTokenFixture.php rename to tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php index cd4bc687..a3d16ea7 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Identity/DetachedAdministratorTokenFixture.php +++ b/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity; +namespace PhpList\Core\Tests\Integration\Domain\Identity\Fixtures; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Identity\AdministratorToken; +use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Fixtures/Identity/DetachedAdministratorTokens.csv b/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokens.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Identity/DetachedAdministratorTokens.csv rename to tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokens.csv diff --git a/tests/Integration/Domain/Repository/Identity/AdministratorRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php similarity index 95% rename from tests/Integration/Domain/Repository/Identity/AdministratorRepositoryTest.php rename to tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php index 05d0b83c..2732f736 100644 --- a/tests/Integration/Domain/Repository/Identity/AdministratorRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Identity; +namespace PhpList\Core\Tests\Integration\Domain\Identity\Repository; use DateTime; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity\AdministratorFixture; +use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorFixture; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** diff --git a/tests/Integration/Domain/Repository/Identity/AdministratorTokenRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php similarity index 92% rename from tests/Integration/Domain/Repository/Identity/AdministratorTokenRepositoryTest.php rename to tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php index b37d4e79..52feeb8c 100644 --- a/tests/Integration/Domain/Repository/Identity/AdministratorTokenRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Identity; +namespace PhpList\Core\Tests\Integration\Domain\Identity\Repository; use DateTime; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Identity\AdministratorToken; -use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; -use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\AdministratorToken; +use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; +use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity\AdministratorFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity\DetachedAdministratorTokenFixture; +use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\DetachedAdministratorTokenFixture; +use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorFixture; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; /** diff --git a/tests/Integration/Domain/Repository/Fixtures/Messaging/Message.csv b/tests/Integration/Domain/Messaging/Fixtures/Message.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Messaging/Message.csv rename to tests/Integration/Domain/Messaging/Fixtures/Message.csv diff --git a/tests/Integration/Domain/Repository/Fixtures/Messaging/MessageFixture.php b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php similarity index 85% rename from tests/Integration/Domain/Repository/Fixtures/Messaging/MessageFixture.php rename to tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php index b65e66c2..52930d14 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Messaging/MessageFixture.php +++ b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Messaging; +namespace PhpList\Core\Tests\Integration\Domain\Messaging\Fixtures; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Messaging\Message; -use PhpList\Core\Domain\Model\Messaging\Message\MessageContent; -use PhpList\Core\Domain\Model\Messaging\Message\MessageFormat; -use PhpList\Core\Domain\Model\Messaging\Message\MessageMetadata; -use PhpList\Core\Domain\Model\Messaging\Message\MessageOptions; -use PhpList\Core\Domain\Model\Messaging\Message\MessageSchedule; -use PhpList\Core\Domain\Model\Messaging\Template; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; +use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; +use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; +use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; +use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Fixtures/Messaging/Template.csv b/tests/Integration/Domain/Messaging/Fixtures/Template.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Messaging/Template.csv rename to tests/Integration/Domain/Messaging/Fixtures/Template.csv diff --git a/tests/Integration/Domain/Repository/Fixtures/Messaging/TemplateFixture.php b/tests/Integration/Domain/Messaging/Fixtures/TemplateFixture.php similarity index 90% rename from tests/Integration/Domain/Repository/Fixtures/Messaging/TemplateFixture.php rename to tests/Integration/Domain/Messaging/Fixtures/TemplateFixture.php index e5222290..f1456acb 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Messaging/TemplateFixture.php +++ b/tests/Integration/Domain/Messaging/Fixtures/TemplateFixture.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Messaging; +namespace PhpList\Core\Tests\Integration\Domain\Messaging\Fixtures; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Messaging\Template; +use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Messaging/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php similarity index 87% rename from tests/Integration/Domain/Repository/Messaging/MessageRepositoryTest.php rename to tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index fc16a416..d7435815 100644 --- a/tests/Integration/Domain/Repository/Messaging/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Messaging; +namespace PhpList\Core\Tests\Integration\Domain\Messaging\Repository; use DateTime; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Messaging\Message; -use PhpList\Core\Domain\Model\Messaging\Message\MessageContent; -use PhpList\Core\Domain\Model\Messaging\Message\MessageFormat; -use PhpList\Core\Domain\Model\Messaging\Message\MessageMetadata; -use PhpList\Core\Domain\Model\Messaging\Message\MessageOptions; -use PhpList\Core\Domain\Model\Messaging\Message\MessageSchedule; -use PhpList\Core\Domain\Repository\Messaging\MessageRepository; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; +use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; +use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; +use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; diff --git a/tests/Integration/Domain/Repository/Messaging/SubscriberListRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php similarity index 89% rename from tests/Integration/Domain/Repository/Messaging/SubscriberListRepositoryTest.php rename to tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php index c721cb57..0f122582 100644 --- a/tests/Integration/Domain/Repository/Messaging/SubscriberListRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php @@ -2,23 +2,23 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Messaging; +namespace PhpList\Core\Tests\Integration\Domain\Messaging\Repository; use DateTime; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Model\Subscription\Subscription; -use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository; +use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity\AdministratorFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriberFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriberListFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriptionFixture; +use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriberFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriberListFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriptionFixture; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** diff --git a/tests/Integration/Domain/Repository/Messaging/TemplateRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/TemplateRepositoryTest.php similarity index 88% rename from tests/Integration/Domain/Repository/Messaging/TemplateRepositoryTest.php rename to tests/Integration/Domain/Messaging/Repository/TemplateRepositoryTest.php index 2d49df1e..672675b2 100644 --- a/tests/Integration/Domain/Repository/Messaging/TemplateRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/TemplateRepositoryTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Messaging; +namespace PhpList\Core\Tests\Integration\Domain\Messaging\Repository; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Messaging\Template; -use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; +use PhpList\Core\Domain\Messaging\Model\Template; +use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Messaging\TemplateFixture; +use PhpList\Core\Tests\Integration\Domain\Messaging\Fixtures\TemplateFixture; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class TemplateRepositoryTest extends KernelTestCase diff --git a/tests/Integration/Domain/Repository/Fixtures/Subscription/Subscriber.csv b/tests/Integration/Domain/Subscription/Fixtures/Subscriber.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Subscription/Subscriber.csv rename to tests/Integration/Domain/Subscription/Fixtures/Subscriber.csv diff --git a/tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberFixture.php b/tests/Integration/Domain/Subscription/Fixtures/SubscriberFixture.php similarity index 93% rename from tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberFixture.php rename to tests/Integration/Domain/Subscription/Fixtures/SubscriberFixture.php index a9fc5867..9c74dd1b 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberFixture.php +++ b/tests/Integration/Domain/Subscription/Fixtures/SubscriberFixture.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription; +namespace PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Subscription\Subscriber; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberList.csv b/tests/Integration/Domain/Subscription/Fixtures/SubscriberList.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberList.csv rename to tests/Integration/Domain/Subscription/Fixtures/SubscriberList.csv diff --git a/tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberListFixture.php b/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php similarity index 91% rename from tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberListFixture.php rename to tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php index 954c3c0e..133d2248 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriberListFixture.php +++ b/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription; +namespace PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Fixtures/Subscription/Subscription.csv b/tests/Integration/Domain/Subscription/Fixtures/Subscription.csv similarity index 100% rename from tests/Integration/Domain/Repository/Fixtures/Subscription/Subscription.csv rename to tests/Integration/Domain/Subscription/Fixtures/Subscription.csv diff --git a/tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriptionFixture.php b/tests/Integration/Domain/Subscription/Fixtures/SubscriptionFixture.php similarity index 87% rename from tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriptionFixture.php rename to tests/Integration/Domain/Subscription/Fixtures/SubscriptionFixture.php index df1741c5..72f15f7d 100644 --- a/tests/Integration/Domain/Repository/Fixtures/Subscription/SubscriptionFixture.php +++ b/tests/Integration/Domain/Subscription/Fixtures/SubscriptionFixture.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription; +namespace PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Model\Subscription\Subscription; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Model\Subscription; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Domain/Repository/Subscription/SubscriberRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php similarity index 91% rename from tests/Integration/Domain/Repository/Subscription/SubscriberRepositoryTest.php rename to tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php index 7c779bfd..91d871d5 100644 --- a/tests/Integration/Domain/Repository/Subscription/SubscriberRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php @@ -2,23 +2,23 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Subscription; +namespace PhpList\Core\Tests\Integration\Domain\Subscription\Repository; use DateTime; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\Subscription; -use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity\AdministratorFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriberFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriberListFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriptionFixture; +use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriberFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriberListFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriptionFixture; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** diff --git a/tests/Integration/Domain/Repository/Subscription/SubscriptionRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php similarity index 92% rename from tests/Integration/Domain/Repository/Subscription/SubscriptionRepositoryTest.php rename to tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php index e394a3ff..de18894c 100644 --- a/tests/Integration/Domain/Repository/Subscription/SubscriptionRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Domain\Repository\Subscription; +namespace PhpList\Core\Tests\Integration\Domain\Subscription\Repository; use DateTime; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Model\Subscription\Subscription; -use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriberFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriberListFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Subscription\SubscriptionFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriberFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriberListFixture; +use PhpList\Core\Tests\Integration\Domain\Subscription\Fixtures\SubscriptionFixture; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; /** diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php new file mode 100644 index 00000000..c2ca4f7d --- /dev/null +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php @@ -0,0 +1,78 @@ +loadSchema(); + + $this->subscriberCsvExportManager = self::getContainer()->get(SubscriberCsvExporter::class); + $this->subscriberRepository = self::getContainer()->get(SubscriberRepository::class); + } + + public function testExportToCsvReturnsStreamedResponse(): void + { + $subscriber1 = new Subscriber(); + $subscriber1->setEmail('test1@example.com'); + $subscriber1->setConfirmed(true); + $subscriber1->setHtmlEmail(true); + $subscriber1->setBlacklisted(false); + $subscriber1->setDisabled(false); + $subscriber1->setExtraData('Data 1'); + $this->entityManager->persist($subscriber1); + + $subscriber2 = new Subscriber(); + $subscriber2->setEmail('test2@example.com'); + $subscriber2->setConfirmed(false); + $subscriber2->setHtmlEmail(false); + $subscriber2->setBlacklisted(true); + $subscriber2->setDisabled(true); + $subscriber2->setExtraData('Data 2'); + $this->entityManager->persist($subscriber2); + + $this->entityManager->flush(); + + $savedSubscribers = $this->subscriberRepository->findAll(); + self::assertCount(2, $savedSubscribers); + + $filter = new SubscriberFilter(columns: ['email', 'confirmed', 'blacklisted', 'disabled', 'extraData']); + + $response = $this->subscriberCsvExportManager->exportToCsv($filter); + + self::assertInstanceOf(Response::class, $response); + self::assertSame('text/csv; charset=utf-8', $response->headers->get('Content-Type')); + self::assertStringContainsString( + 'attachment; filename=subscribers_export_', + $response->headers->get('Content-Disposition') + ); + + ob_start(); + $response->sendContent(); + $content = ob_get_clean(); + + self::assertStringContainsString('email,confirmed,blacklisted,disabled,extraData', $content); + } +} diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php new file mode 100644 index 00000000..0e84fdec --- /dev/null +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -0,0 +1,135 @@ +loadSchema(); + + $this->subscriberCsvImportManager = self::getContainer()->get(SubscriberCsvImporter::class); + $this->subscriberRepository = self::getContainer()->get(SubscriberRepository::class); + } + + public function testImportFromCsvCreatesNewSubscribers(): void + { + $attributeDefinition = new SubscriberAttributeDefinition(); + $attributeDefinition->setName('first_name'); + $this->entityManager->persist($attributeDefinition); + $this->entityManager->flush(); + + $csvContent = "email,confirmed,html_email,blacklisted,disabled,extra_data,first_name\n"; + $csvContent .= "test@example.com,1,1,0,0,\"Some extra data\",John\n"; + $csvContent .= "another@example.com,0,0,1,1,\"More data\",Jane\n"; + + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test'); + file_put_contents($tempFile, $csvContent); + + $uploadedFile = new UploadedFile( + $tempFile, + 'subscribers.csv', + 'text/csv', + null, + true + ); + + $subscriberCountBefore = count($this->subscriberRepository->findAll()); + + $options = new SubscriberImportOptions(); + $result = $this->subscriberCsvImportManager->importFromCsv($uploadedFile, $options); + + $subscriberCountAfter = count($this->subscriberRepository->findAll()); + + self::assertSame(2, $result['created']); + self::assertSame(0, $result['updated']); + self::assertSame(0, $result['skipped']); + self::assertEmpty($result['errors']); + self::assertSame($subscriberCountBefore + 2, $subscriberCountAfter); + + $subscriber1 = $this->subscriberRepository->findOneByEmail('test@example.com'); + self::assertInstanceOf(Subscriber::class, $subscriber1); + self::assertTrue($subscriber1->isConfirmed()); + self::assertTrue($subscriber1->hasHtmlEmail()); + self::assertFalse($subscriber1->isBlacklisted()); + self::assertFalse($subscriber1->isDisabled()); + self::assertSame('Some extra data', $subscriber1->getExtraData()); + + $subscriber2 = $this->subscriberRepository->findOneByEmail('another@example.com'); + self::assertInstanceOf(Subscriber::class, $subscriber2); + self::assertFalse($subscriber2->isConfirmed()); + self::assertFalse($subscriber2->hasHtmlEmail()); + self::assertTrue($subscriber2->isBlacklisted()); + self::assertTrue($subscriber2->isDisabled()); + self::assertSame('More data', $subscriber2->getExtraData()); + + unlink($tempFile); + } + + public function testImportFromCsvUpdatesExistingSubscribers(): void + { + $subscriber = new Subscriber(); + $subscriber->setEmail('existing@example.com'); + $subscriber->setConfirmed(false); + $subscriber->setHtmlEmail(false); + $subscriber->setBlacklisted(true); + $subscriber->setDisabled(true); + $subscriber->setExtraData('Old data'); + $this->entityManager->persist($subscriber); + $this->entityManager->flush(); + + $csvContent = "email,confirmed,html_email,blacklisted,disabled,extra_data\n"; + $csvContent .= "existing@example.com,1,1,0,0,\"Updated data\"\n"; + + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test'); + file_put_contents($tempFile, $csvContent); + + $uploadedFile = new UploadedFile( + $tempFile, + 'subscribers.csv', + 'text/csv', + null, + true + ); + + $options = new SubscriberImportOptions(updateExisting: true); + $result = $this->subscriberCsvImportManager->importFromCsv($uploadedFile, $options); + + self::assertSame(0, $result['created']); + self::assertSame(1, $result['updated']); + self::assertSame(0, $result['skipped']); + self::assertEmpty($result['errors']); + + $updatedSubscriber = $this->subscriberRepository->findOneByEmail('existing@example.com'); + self::assertInstanceOf(Subscriber::class, $updatedSubscriber); + self::assertTrue($updatedSubscriber->isConfirmed()); + self::assertTrue($updatedSubscriber->hasHtmlEmail()); + self::assertFalse($updatedSubscriber->isBlacklisted()); + self::assertFalse($updatedSubscriber->isDisabled()); + self::assertSame('Updated data', $updatedSubscriber->getExtraData()); + + unlink($tempFile); + } +} diff --git a/tests/Integration/Security/AuthenticationTest.php b/tests/Integration/Security/AuthenticationTest.php index a46a6d93..8b1d2d0e 100644 --- a/tests/Integration/Security/AuthenticationTest.php +++ b/tests/Integration/Security/AuthenticationTest.php @@ -5,11 +5,11 @@ namespace PhpList\Core\Tests\Integration\Security; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Domain\Model\Identity\Administrator; +use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Security\Authentication; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity\AdministratorFixture; -use PhpList\Core\Tests\Integration\Domain\Repository\Fixtures\Identity\AdministratorTokenWithAdministratorFixture; +use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorFixture; +use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorTokenWithAdministratorFixture; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php new file mode 100644 index 00000000..a9558724 --- /dev/null +++ b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php @@ -0,0 +1,328 @@ +linkTrackManager = $this->createMock(LinkTrackManager::class); + $this->userMessageViewManager = $this->createMock(UserMessageViewManager::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->userMessageForwardRepository = $this->createMock(UserMessageForwardRepository::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + + $this->subject = new AnalyticsService( + $this->linkTrackManager, + $this->userMessageViewManager, + $this->messageRepository, + $this->userMessageBounceRepository, + $this->userMessageForwardRepository, + $this->subscriberRepository + ); + } + + public function testGetCampaignStatistics(): void + { + $limit = 50; + $lastId = 0; + $messageId = 123; + + $messageMetadata = $this->createMock(MessageMetadata::class); + $messageMetadata->method('getSent')->willReturn(new DateTime('2023-01-01 10:00:00')); + $messageMetadata->method('getBounceCount')->willReturn(5); + + $messageContent = $this->createMock(MessageContent::class); + $messageContent->method('getSubject')->willReturn('Test Campaign'); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getMetadata')->willReturn($messageMetadata); + $message->method('getContent')->willReturn($messageContent); + + $linkTrack1 = new LinkTrack(); + $linkTrack1->setUserId(1); + $linkTrack1->setClicked(2); + + $linkTrack2 = new LinkTrack(); + $linkTrack2->setUserId(2); + $linkTrack2->setClicked(3); + + $this->messageRepository->expects(self::once()) + ->method('getFilteredAfterId') + ->with($lastId, $limit) + ->willReturn([$message]); + + $this->userMessageViewManager->expects(self::once()) + ->method('countViewsByMessageId') + ->with($messageId) + ->willReturn(10); + + $this->linkTrackManager->expects(self::once()) + ->method('getLinkTracksByMessageId') + ->with($messageId) + ->willReturn([$linkTrack1, $linkTrack2]); + + $this->userMessageBounceRepository->expects(self::once()) + ->method('getCountByMessageId') + ->with($messageId) + ->willReturn(3); + + $this->userMessageForwardRepository->expects(self::once()) + ->method('getCountByMessageId') + ->with($messageId) + ->willReturn(2); + + $result = $this->subject->getCampaignStatistics($limit, $lastId); + + self::assertArrayHasKey('campaigns', $result); + self::assertCount(1, $result['campaigns']); + self::assertSame(1, $result['total']); + self::assertFalse($result['hasMore']); + self::assertSame($messageId, $result['lastId']); + + $campaign = $result['campaigns'][0]; + self::assertSame($messageId, $campaign['campaignId']); + self::assertSame('Test Campaign', $campaign['subject']); + self::assertSame('2023-01-01 10:00:00', $campaign['dateSent']); + self::assertSame(15, $campaign['sent']); + self::assertSame(3, $campaign['bounces']); + self::assertSame(2, $campaign['forwards']); + self::assertSame(10, $campaign['uniqueViews']); + self::assertSame(5, $campaign['totalClicks']); + self::assertSame(2, $campaign['uniqueClicks']); + } + + public function testGetViewOpensStatistics(): void + { + $limit = 50; + $lastId = 0; + $messageId = 123; + + $messageMetadata = $this->createMock(MessageMetadata::class); + $messageMetadata->method('getBounceCount')->willReturn(5); + + $messageContent = $this->createMock(MessageContent::class); + $messageContent->method('getSubject')->willReturn('Test Campaign'); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getMetadata')->willReturn($messageMetadata); + $message->method('getContent')->willReturn($messageContent); + + $this->messageRepository->expects(self::once()) + ->method('getFilteredAfterId') + ->with($lastId, $limit) + ->willReturn([$message]); + + $this->userMessageViewManager->expects(self::once()) + ->method('countViewsByMessageId') + ->with($messageId) + ->willReturn(10); + + $result = $this->subject->getViewOpensStatistics($limit, $lastId); + + self::assertArrayHasKey('campaigns', $result); + self::assertCount(1, $result['campaigns']); + self::assertSame(1, $result['total']); + self::assertFalse($result['hasMore']); + self::assertSame($messageId, $result['lastId']); + + $campaign = $result['campaigns'][0]; + self::assertSame($messageId, $campaign['campaignId']); + self::assertSame('Test Campaign', $campaign['subject']); + self::assertSame(15, $campaign['sent']); + self::assertSame(10, $campaign['uniqueViews']); + self::assertSame(66.7, $campaign['rate']); + } + + public function testGetTopDomains(): void + { + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getEmail')->willReturn('user1@example.com'); + + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getEmail')->willReturn('user2@example.com'); + + $subscriber3 = $this->createMock(Subscriber::class); + $subscriber3->method('getEmail')->willReturn('user3@example.com'); + + $subscriber4 = $this->createMock(Subscriber::class); + $subscriber4->method('getEmail')->willReturn('user4@example.com'); + + $subscriber5 = $this->createMock(Subscriber::class); + $subscriber5->method('getEmail')->willReturn('user5@example.com'); + + $subscriber6 = $this->createMock(Subscriber::class); + $subscriber6->method('getEmail')->willReturn('user6@example.com'); + + $subscriber7 = $this->createMock(Subscriber::class); + $subscriber7->method('getEmail')->willReturn('user1@test.com'); + + $subscriber8 = $this->createMock(Subscriber::class); + $subscriber8->method('getEmail')->willReturn('user2@test.com'); + + $subscriber9 = $this->createMock(Subscriber::class); + $subscriber9->method('getEmail')->willReturn('user3@another.com'); + + $this->subscriberRepository->expects(self::once()) + ->method('findAll') + ->willReturn([ + $subscriber1, $subscriber2, $subscriber3, $subscriber4, $subscriber5, + $subscriber6, $subscriber7, $subscriber8, $subscriber9 + ]); + + $result = $this->subject->getTopDomains(50, 1); + + self::assertArrayHasKey('domains', $result); + self::assertArrayHasKey('total', $result); + + self::assertSame(3, $result['total']); + + self::assertSame('example.com', $result['domains'][0]['domain']); + self::assertSame(6, $result['domains'][0]['subscribers']); + + self::assertSame('test.com', $result['domains'][1]['domain']); + self::assertSame(2, $result['domains'][1]['subscribers']); + + self::assertSame('another.com', $result['domains'][2]['domain']); + self::assertSame(1, $result['domains'][2]['subscribers']); + } + + public function testGetDomainConfirmationStatistics(): void + { + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getEmail')->willReturn('user1@example.com'); + $subscriber1->method('isConfirmed')->willReturn(true); + $subscriber1->method('isBlacklisted')->willReturn(false); + + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getEmail')->willReturn('user2@example.com'); + $subscriber2->method('isConfirmed')->willReturn(true); + $subscriber2->method('isBlacklisted')->willReturn(false); + + $subscriber3 = $this->createMock(Subscriber::class); + $subscriber3->method('getEmail')->willReturn('user3@example.com'); + $subscriber3->method('isConfirmed')->willReturn(false); + $subscriber3->method('isBlacklisted')->willReturn(false); + + $subscriber4 = $this->createMock(Subscriber::class); + $subscriber4->method('getEmail')->willReturn('user4@example.com'); + $subscriber4->method('isConfirmed')->willReturn(false); + $subscriber4->method('isBlacklisted')->willReturn(false); + + $subscriber5 = $this->createMock(Subscriber::class); + $subscriber5->method('getEmail')->willReturn('user5@example.com'); + $subscriber5->method('isConfirmed')->willReturn(false); + $subscriber5->method('isBlacklisted')->willReturn(true); + + $subscriber6 = $this->createMock(Subscriber::class); + $subscriber6->method('getEmail')->willReturn('user1@test.com'); + $subscriber6->method('isConfirmed')->willReturn(true); + $subscriber6->method('isBlacklisted')->willReturn(false); + + $subscriber7 = $this->createMock(Subscriber::class); + $subscriber7->method('getEmail')->willReturn('user2@test.com'); + $subscriber7->method('isConfirmed')->willReturn(false); + $subscriber7->method('isBlacklisted')->willReturn(false); + + $this->subscriberRepository->expects(self::once()) + ->method('findAll') + ->willReturn([ + $subscriber1, $subscriber2, $subscriber3, $subscriber4, + $subscriber5, $subscriber6, $subscriber7 + ]); + + $result = $this->subject->getDomainConfirmationStatistics(); + + self::assertArrayHasKey('domains', $result); + self::assertArrayHasKey('total', $result); + + self::assertSame(2, $result['total']); + + $exampleDomain = $result['domains'][0]; + self::assertSame('example.com', $exampleDomain['domain']); + self::assertSame(2, $exampleDomain['confirmed']['count']); + self::assertSame(40, $exampleDomain['confirmed']['percentage']); + self::assertSame(2, $exampleDomain['unconfirmed']['count']); + self::assertSame(40, $exampleDomain['unconfirmed']['percentage']); + self::assertSame(1, $exampleDomain['blacklisted']['count']); + self::assertSame(20, $exampleDomain['blacklisted']['percentage']); + self::assertSame(5, $exampleDomain['total']['count']); + + $testDomain = $result['domains'][1]; + self::assertSame('test.com', $testDomain['domain']); + self::assertSame(1, $testDomain['confirmed']['count']); + self::assertSame(50, $testDomain['confirmed']['percentage']); + self::assertSame(1, $testDomain['unconfirmed']['count']); + self::assertSame(50, $testDomain['unconfirmed']['percentage']); + self::assertSame(0, $testDomain['blacklisted']['count']); + self::assertSame(0, $testDomain['blacklisted']['percentage']); + self::assertSame(2, $testDomain['total']['count']); + } + + public function testGetTopLocalParts(): void + { + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getEmail')->willReturn('user1@example.com'); + + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getEmail')->willReturn('user2@example.com'); + + $subscriber3 = $this->createMock(Subscriber::class); + $subscriber3->method('getEmail')->willReturn('user1@test.com'); + + $subscriber4 = $this->createMock(Subscriber::class); + $subscriber4->method('getEmail')->willReturn('admin@example.com'); + + $subscriber5 = $this->createMock(Subscriber::class); + $subscriber5->method('getEmail')->willReturn('info@example.com'); + + $this->subscriberRepository->expects(self::once()) + ->method('findAll') + ->willReturn([ + $subscriber1, $subscriber2, $subscriber3, $subscriber4, $subscriber5 + ]); + + $result = $this->subject->getTopLocalParts(); + + self::assertArrayHasKey('localParts', $result); + self::assertArrayHasKey('total', $result); + + self::assertSame(4, $result['total']); + + self::assertSame('user1', $result['localParts'][0]['localPart']); + self::assertSame(2, $result['localParts'][0]['count']); + self::assertSame(40, $result['localParts'][0]['percentage']); + + self::assertSame(1, $result['localParts'][1]['count']); + self::assertSame(20, $result['localParts'][1]['percentage']); + } +} diff --git a/tests/Unit/Domain/Repository/CursorPaginationTraitTest.php b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php similarity index 93% rename from tests/Unit/Domain/Repository/CursorPaginationTraitTest.php rename to tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php index 1639b633..137da779 100644 --- a/tests/Unit/Domain/Repository/CursorPaginationTraitTest.php +++ b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository; +namespace PhpList\Core\Tests\Unit\Domain\Common\Repository; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; -use PhpList\Core\Domain\Filter\FilterRequestInterface; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Unit/Domain/Repository/DummyRepository.php b/tests/Unit/Domain/Common/Repository/DummyRepository.php similarity index 78% rename from tests/Unit/Domain/Repository/DummyRepository.php rename to tests/Unit/Domain/Common/Repository/DummyRepository.php index e16b612e..7c06b537 100644 --- a/tests/Unit/Domain/Repository/DummyRepository.php +++ b/tests/Unit/Domain/Common/Repository/DummyRepository.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository; +namespace PhpList\Core\Tests\Unit\Domain\Common\Repository; use Doctrine\ORM\QueryBuilder; -use PhpList\Core\Domain\Repository\CursorPaginationTrait; +use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; /** * Dummy repository that uses the trait diff --git a/tests/Unit/Domain/Identity/Model/AdminAttributeDefinitionTest.php b/tests/Unit/Domain/Identity/Model/AdminAttributeDefinitionTest.php new file mode 100644 index 00000000..0343d179 --- /dev/null +++ b/tests/Unit/Domain/Identity/Model/AdminAttributeDefinitionTest.php @@ -0,0 +1,211 @@ +subject = new AdminAttributeDefinition( + $this->name, + $this->type, + $this->listOrder, + $this->defaultValue, + $this->required, + $this->tableName + ); + } + + public function testIsDomainModel(): void + { + self::assertInstanceOf(DomainModel::class, $this->subject); + } + + public function testGetIdInitiallyReturnsNull(): void + { + self::assertNull($this->subject->getId()); + } + + public function testGetIdReturnsId(): void + { + $id = 123456; + $this->setSubjectId($this->subject, $id); + + self::assertSame($id, $this->subject->getId()); + } + + public function testGetNameReturnsName(): void + { + self::assertSame($this->name, $this->subject->getName()); + } + + public function testSetNameSetsName(): void + { + $newName = 'new-name'; + $this->subject->setName($newName); + + self::assertSame($newName, $this->subject->getName()); + } + + public function testSetNameReturnsInstance(): void + { + $result = $this->subject->setName('new-name'); + + self::assertSame($this->subject, $result); + } + + public function testGetTypeReturnsType(): void + { + self::assertSame($this->type, $this->subject->getType()); + } + + public function testSetTypeSetsType(): void + { + $newType = 'checkbox'; + $this->subject->setType($newType); + + self::assertSame($newType, $this->subject->getType()); + } + + public function testSetTypeReturnsInstance(): void + { + $result = $this->subject->setType('checkbox'); + + self::assertSame($this->subject, $result); + } + + public function testSetTypeCanSetNull(): void + { + $this->subject->setType(null); + + self::assertNull($this->subject->getType()); + } + + public function testGetListOrderReturnsListOrder(): void + { + self::assertSame($this->listOrder, $this->subject->getListOrder()); + } + + public function testSetListOrderSetsListOrder(): void + { + $newListOrder = 20; + $this->subject->setListOrder($newListOrder); + + self::assertSame($newListOrder, $this->subject->getListOrder()); + } + + public function testSetListOrderReturnsInstance(): void + { + $result = $this->subject->setListOrder(20); + + self::assertSame($this->subject, $result); + } + + public function testSetListOrderCanSetNull(): void + { + $this->subject->setListOrder(null); + + self::assertNull($this->subject->getListOrder()); + } + + public function testGetDefaultValueReturnsDefaultValue(): void + { + self::assertSame($this->defaultValue, $this->subject->getDefaultValue()); + } + + public function testSetDefaultValueSetsDefaultValue(): void + { + $newDefaultValue = 'new-default'; + $this->subject->setDefaultValue($newDefaultValue); + + self::assertSame($newDefaultValue, $this->subject->getDefaultValue()); + } + + public function testSetDefaultValueReturnsInstance(): void + { + $result = $this->subject->setDefaultValue('new-default'); + + self::assertSame($this->subject, $result); + } + + public function testSetDefaultValueCanSetNull(): void + { + $this->subject->setDefaultValue(null); + + self::assertNull($this->subject->getDefaultValue()); + } + + public function testIsRequiredReturnsRequired(): void + { + self::assertSame($this->required, $this->subject->isRequired()); + } + + public function testSetRequiredSetsRequired(): void + { + $this->subject->setRequired(false); + + self::assertSame(false, $this->subject->isRequired()); + } + + public function testSetRequiredReturnsInstance(): void + { + $result = $this->subject->setRequired(false); + + self::assertSame($this->subject, $result); + } + + public function testSetRequiredCanSetNull(): void + { + $this->subject->setRequired(null); + + self::assertNull($this->subject->isRequired()); + } + + public function testGetTableNameReturnsTableName(): void + { + self::assertSame($this->tableName, $this->subject->getTableName()); + } + + public function testSetTableNameSetsTableName(): void + { + $newTableName = 'new_table'; + $this->subject->setTableName($newTableName); + + self::assertSame($newTableName, $this->subject->getTableName()); + } + + public function testSetTableNameReturnsInstance(): void + { + $result = $this->subject->setTableName('new_table'); + + self::assertSame($this->subject, $result); + } + + public function testSetTableNameCanSetNull(): void + { + $this->subject->setTableName(null); + + self::assertNull($this->subject->getTableName()); + } +} diff --git a/tests/Unit/Domain/Identity/Model/AdminAttributeValueTest.php b/tests/Unit/Domain/Identity/Model/AdminAttributeValueTest.php new file mode 100644 index 00000000..778f930c --- /dev/null +++ b/tests/Unit/Domain/Identity/Model/AdminAttributeValueTest.php @@ -0,0 +1,77 @@ +attributeDefinition = $this->createMock(AdminAttributeDefinition::class); + $this->attributeDefinition->method('getId')->willReturn($this->adminAttributeId); + + $this->administrator = $this->createMock(Administrator::class); + $this->administrator->method('getId')->willReturn($this->adminId); + + $this->subject = new AdminAttributeValue($this->attributeDefinition, $this->administrator, $this->value); + } + + public function testIsDomainModel(): void + { + self::assertInstanceOf(DomainModel::class, $this->subject); + } + + public function testGetAttributeDefinitionReturnsAttributeDefinition(): void + { + self::assertSame($this->attributeDefinition, $this->subject->getAttributeDefinition()); + } + + public function testGetAdministratorReturnsAdministrator(): void + { + self::assertSame($this->administrator, $this->subject->getAdministrator()); + } + + public function testGetValueReturnsValue(): void + { + self::assertSame($this->value, $this->subject->getValue()); + } + + public function testSetValueSetsValue(): void + { + $newValue = 'new-value'; + $this->subject->setValue($newValue); + + self::assertSame($newValue, $this->subject->getValue()); + } + + public function testSetValueReturnsInstance(): void + { + $result = $this->subject->setValue('new-value'); + + self::assertSame($this->subject, $result); + } + + public function testSetValueCanSetNull(): void + { + $this->subject->setValue(null); + + self::assertNull($this->subject->getValue()); + } +} diff --git a/tests/Unit/Domain/Model/Identity/AdministratorTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTest.php similarity index 95% rename from tests/Unit/Domain/Model/Identity/AdministratorTest.php rename to tests/Unit/Domain/Identity/Model/AdministratorTest.php index e5565c64..10508b26 100644 --- a/tests/Unit/Domain/Model/Identity/AdministratorTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Model\Identity; +namespace PhpList\Core\Tests\Unit\Domain\Identity\Model; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Model/Identity/AdministratorTokenTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php similarity index 92% rename from tests/Unit/Domain/Model/Identity/AdministratorTokenTest.php rename to tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php index 5318782f..da824845 100644 --- a/tests/Unit/Domain/Model/Identity/AdministratorTokenTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Model\Identity; +namespace PhpList\Core\Tests\Unit\Domain\Identity\Model; use DateTime; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Identity\AdministratorToken; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Repository/AdminAttributeValueRepositoryTest.php b/tests/Unit/Domain/Identity/Repository/AdminAttributeValueRepositoryTest.php new file mode 100644 index 00000000..99695df1 --- /dev/null +++ b/tests/Unit/Domain/Identity/Repository/AdminAttributeValueRepositoryTest.php @@ -0,0 +1,111 @@ +entityManager = $this->createMock(EntityManager::class); + $classMetadata = $this->createMock(ClassMetadata::class); + $classMetadata->name = AdminAttributeValue::class; + + $this->queryBuilder = $this->createMock(QueryBuilder::class); + $this->query = $this->createMock(Query::class); + + $this->subject = new AdminAttributeValueRepository($this->entityManager, $classMetadata); + } + + public function testIsAbstractRepository(): void + { + self::assertInstanceOf(AbstractRepository::class, $this->subject); + } + + public function testFindOneByAdminIdAndAttributeId(): void + { + $adminId = 1; + $attributeId = 2; + + $attributeDefinition = $this->createMock(AdminAttributeDefinition::class); + $attributeDefinition->method('getId')->willReturn($attributeId); + + $administrator = $this->createMock(Administrator::class); + $administrator->method('getId')->willReturn($adminId); + + $expectedResult = new AdminAttributeValue($attributeDefinition, $administrator, 'value'); + + $this->entityManager->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($this->queryBuilder); + + $this->queryBuilder->expects($this->once()) + ->method('select') + ->with('aav') + ->willReturn($this->queryBuilder); + + $this->queryBuilder->expects($this->once()) + ->method('from') + ->with(AdminAttributeValue::class, 'aav') + ->willReturn($this->queryBuilder); + + $this->queryBuilder->expects($this->exactly(2)) + ->method('join') + ->withConsecutive( + ['aav.administrator', 'admin'], + ['aav.attributeDefinition', 'attr'] + ) + ->willReturn($this->queryBuilder); + + $this->queryBuilder->expects($this->once()) + ->method('where') + ->with('admin.id = :adminId') + ->willReturn($this->queryBuilder); + + $this->queryBuilder->expects($this->once()) + ->method('andWhere') + ->with('attr.id = :attributeId') + ->willReturn($this->queryBuilder); + + $this->queryBuilder->expects($this->exactly(2)) + ->method('setParameter') + ->withConsecutive( + ['adminId', $adminId], + ['attributeId', $attributeId] + ) + ->willReturn($this->queryBuilder); + + $this->queryBuilder->expects($this->once()) + ->method('getQuery') + ->willReturn($this->query); + + $this->query->expects($this->once()) + ->method('getOneOrNullResult') + ->willReturn($expectedResult); + + $result = $this->subject->findOneByAdminIdAndAttributeId($adminId, $attributeId); + + $this->assertSame($expectedResult, $result); + } +} diff --git a/tests/Unit/Domain/Repository/Identity/AdministratorRepositoryTest.php b/tests/Unit/Domain/Identity/Rpository/AdministratorRepositoryTest.php similarity index 76% rename from tests/Unit/Domain/Repository/Identity/AdministratorRepositoryTest.php rename to tests/Unit/Domain/Identity/Rpository/AdministratorRepositoryTest.php index 9acce7cf..d29282d2 100644 --- a/tests/Unit/Domain/Repository/Identity/AdministratorRepositoryTest.php +++ b/tests/Unit/Domain/Identity/Rpository/AdministratorRepositoryTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository\Identity; +namespace PhpList\Core\Tests\Unit\Domain\Identity\Rpository; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; -use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PHPUnit\Framework\TestCase; /** @@ -23,7 +24,7 @@ protected function setUp(): void { $entityManager = $this->createMock(EntityManager::class); $classMetadata = $this->createMock(ClassMetadata::class); - $classMetadata->name = 'PhpList\Core\Domain\Model\Identity\Administrator'; + $classMetadata->name = Administrator::class; $this->subject = new AdministratorRepository($entityManager, $classMetadata); } diff --git a/tests/Unit/Domain/Repository/Identity/AdministratorTokenRepositoryTest.php b/tests/Unit/Domain/Identity/Rpository/AdministratorTokenRepositoryTest.php similarity index 76% rename from tests/Unit/Domain/Repository/Identity/AdministratorTokenRepositoryTest.php rename to tests/Unit/Domain/Identity/Rpository/AdministratorTokenRepositoryTest.php index 1b913b40..408f75ca 100644 --- a/tests/Unit/Domain/Repository/Identity/AdministratorTokenRepositoryTest.php +++ b/tests/Unit/Domain/Identity/Rpository/AdministratorTokenRepositoryTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository\Identity; +namespace PhpList\Core\Tests\Unit\Domain\Identity\Rpository; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; -use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; +use PhpList\Core\Domain\Identity\Model\AdministratorToken; +use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use PHPUnit\Framework\TestCase; /** @@ -23,7 +24,7 @@ protected function setUp(): void { $entityManager = $this->createMock(EntityManager::class); $classMetadata = $this->createMock(ClassMetadata::class); - $classMetadata->name = 'PhpList\Core\Domain\Model\Identity\AdministratorToken'; + $classMetadata->name = AdministratorToken::class; $this->subject = new AdministratorTokenRepository($entityManager, $classMetadata); } diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php new file mode 100644 index 00000000..b557e2f0 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -0,0 +1,204 @@ +repository = $this->createMock(AdminAttributeDefinitionRepository::class); + $this->subject = new AdminAttributeDefinitionManager($this->repository); + } + + public function testCreateCreatesNewAttributeDefinition(): void + { + $dto = new AdminAttributeDefinitionDto( + name: 'test-attribute', + type: 'text', + listOrder: 10, + defaultValue: 'default', + required: true, + tableName: 'test_table' + ); + + $this->repository->expects($this->once()) + ->method('findOneByName') + ->with('test-attribute') + ->willReturn(null); + + $this->repository->expects($this->once()) + ->method('save') + ->with($this->callback(function (AdminAttributeDefinition $definition) use ($dto) { + return $definition->getName() === $dto->name + && $definition->getType() === $dto->type + && $definition->getListOrder() === $dto->listOrder + && $definition->getDefaultValue() === $dto->defaultValue + && $definition->isRequired() === $dto->required + && $definition->getTableName() === $dto->tableName; + })); + + $result = $this->subject->create($dto); + + $this->assertInstanceOf(AdminAttributeDefinition::class, $result); + $this->assertEquals($dto->name, $result->getName()); + $this->assertEquals($dto->type, $result->getType()); + $this->assertEquals($dto->listOrder, $result->getListOrder()); + $this->assertEquals($dto->defaultValue, $result->getDefaultValue()); + $this->assertEquals($dto->required, $result->isRequired()); + $this->assertEquals($dto->tableName, $result->getTableName()); + } + + public function testCreateThrowsExceptionIfAttributeAlreadyExists(): void + { + $dto = new AdminAttributeDefinitionDto( + name: 'test-attribute' + ); + + $existingAttribute = $this->createMock(AdminAttributeDefinition::class); + + $this->repository->expects($this->once()) + ->method('findOneByName') + ->with('test-attribute') + ->willReturn($existingAttribute); + + $this->expectException(AttributeDefinitionCreationException::class); + $this->expectExceptionMessage('Attribute definition already exists'); + + $this->subject->create($dto); + } + + public function testUpdateUpdatesAttributeDefinition(): void + { + $attributeDefinition = $this->createMock(AdminAttributeDefinition::class); + $attributeDefinition->method('getId')->willReturn(1); + + $dto = new AdminAttributeDefinitionDto( + name: 'updated-attribute', + type: 'checkbox', + listOrder: 20, + defaultValue: 'new-default', + required: false, + tableName: 'new_table' + ); + + $this->repository->expects($this->once()) + ->method('findOneByName') + ->with('updated-attribute') + ->willReturn(null); + + $attributeDefinition->expects($this->once()) + ->method('setName') + ->with('updated-attribute') + ->willReturnSelf(); + + $attributeDefinition->expects($this->once()) + ->method('setType') + ->with('checkbox') + ->willReturnSelf(); + + $attributeDefinition->expects($this->once()) + ->method('setListOrder') + ->with(20) + ->willReturnSelf(); + + $attributeDefinition->expects($this->once()) + ->method('setDefaultValue') + ->with('new-default') + ->willReturnSelf(); + + $attributeDefinition->expects($this->once()) + ->method('setRequired') + ->with(false) + ->willReturnSelf(); + + $attributeDefinition->expects($this->once()) + ->method('setTableName') + ->with('new_table') + ->willReturnSelf(); + + $this->repository->expects($this->once()) + ->method('save') + ->with($attributeDefinition); + + $result = $this->subject->update($attributeDefinition, $dto); + + $this->assertSame($attributeDefinition, $result); + } + + public function testUpdateThrowsExceptionIfAnotherAttributeWithSameNameExists(): void + { + $attributeDefinition = $this->createMock(AdminAttributeDefinition::class); + $attributeDefinition->method('getId')->willReturn(1); + + $dto = new AdminAttributeDefinitionDto( + name: 'existing-attribute' + ); + + $existingAttribute = $this->createMock(AdminAttributeDefinition::class); + $existingAttribute->method('getId')->willReturn(2); + + $this->repository->expects($this->once()) + ->method('findOneByName') + ->with('existing-attribute') + ->willReturn($existingAttribute); + + $this->expectException(AttributeDefinitionCreationException::class); + $this->expectExceptionMessage('Another attribute with this name already exists.'); + + $this->subject->update($attributeDefinition, $dto); + } + + public function testDeleteCallsRemoveOnRepository(): void + { + $attributeDefinition = $this->createMock(AdminAttributeDefinition::class); + + $this->repository->expects($this->once()) + ->method('remove') + ->with($attributeDefinition); + + $this->subject->delete($attributeDefinition); + } + + public function testGetTotalCountReturnsCountFromRepository(): void + { + $this->repository->expects($this->once()) + ->method('count') + ->willReturn(42); + + $result = $this->subject->getTotalCount(); + + $this->assertEquals(42, $result); + } + + public function testGetAttributesAfterIdReturnsAttributesFromRepository(): void + { + $afterId = 10; + $limit = 20; + $attributes = [ + $this->createMock(AdminAttributeDefinition::class), + $this->createMock(AdminAttributeDefinition::class), + ]; + + $this->repository->expects($this->once()) + ->method('getAfterId') + ->with($afterId, $limit) + ->willReturn($attributes); + + $result = $this->subject->getAttributesAfterId($afterId, $limit); + + $this->assertSame($attributes, $result); + } +} diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php new file mode 100644 index 00000000..9c5cac8f --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php @@ -0,0 +1,171 @@ +repository = $this->createMock(AdminAttributeValueRepository::class); + $this->subject = new AdminAttributeManager($this->repository); + } + + public function testCreateOrUpdateCreatesNewAttributeIfNotExists(): void + { + $admin = $this->createMock(Administrator::class); + $admin->method('getId')->willReturn(1); + + $definition = $this->createMock(AdminAttributeDefinition::class); + $definition->method('getId')->willReturn(2); + $definition->method('getDefaultValue')->willReturn(null); + + $value = 'test-value'; + + $this->repository->expects($this->once()) + ->method('findOneByAdminIdAndAttributeId') + ->with(1, 2) + ->willReturn(null); + + $this->repository->expects($this->once()) + ->method('save') + ->with($this->callback(function (AdminAttributeValue $attribute) use ($value) { + return $attribute->getAdministrator()->getId() === 1 + && $attribute->getAttributeDefinition()->getId() === 2 + && $attribute->getValue() === $value; + })); + + $result = $this->subject->createOrUpdate($admin, $definition, $value); + + $this->assertInstanceOf(AdminAttributeValue::class, $result); + $this->assertEquals(1, $result->getAdministrator()->getId()); + $this->assertEquals(2, $result->getAttributeDefinition()->getId()); + $this->assertEquals($value, $result->getValue()); + } + + public function testCreateOrUpdateUpdatesExistingAttribute(): void + { + $admin = $this->createMock(Administrator::class); + $admin->method('getId')->willReturn(1); + + $definition = $this->createMock(AdminAttributeDefinition::class); + $definition->method('getId')->willReturn(2); + + $existingAttribute = new AdminAttributeValue($definition, $admin, 'old-value'); + $newValue = 'new-value'; + + $this->repository->expects($this->once()) + ->method('findOneByAdminIdAndAttributeId') + ->with(1, 2) + ->willReturn($existingAttribute); + + $this->repository->expects($this->once()) + ->method('save') + ->with($this->callback(function (AdminAttributeValue $attribute) use ($newValue) { + return $attribute->getValue() === $newValue; + })); + + $result = $this->subject->createOrUpdate($admin, $definition, $newValue); + + $this->assertSame($existingAttribute, $result); + $this->assertEquals($newValue, $result->getValue()); + } + + public function testCreateOrUpdateUsesDefaultValueIfValueIsNull(): void + { + $admin = $this->createMock(Administrator::class); + $admin->method('getId')->willReturn(1); + + $defaultValue = 'default-value'; + $definition = $this->createMock(AdminAttributeDefinition::class); + $definition->method('getId')->willReturn(2); + $definition->method('getDefaultValue')->willReturn($defaultValue); + + $this->repository->expects($this->once()) + ->method('findOneByAdminIdAndAttributeId') + ->willReturn(null); + + $this->repository->expects($this->once()) + ->method('save'); + + $result = $this->subject->createOrUpdate($admin, $definition); + + $this->assertEquals($defaultValue, $result->getValue()); + } + + public function testCreateOrUpdateThrowsExceptionIfValueAndDefaultValueAreNull(): void + { + $admin = $this->createMock(Administrator::class); + $admin->method('getId')->willReturn(1); + $definition = $this->createMock(AdminAttributeDefinition::class); + $definition->method('getId')->willReturn(2); + $definition->method('getDefaultValue')->willReturn(null); + + $this->repository->expects($this->once()) + ->method('findOneByAdminIdAndAttributeId') + ->willReturn(null); + + $this->expectException(AdminAttributeCreationException::class); + $this->expectExceptionMessage('Value is required'); + + $this->subject->createOrUpdate($admin, $definition); + } + + public function testGetAdminAttributeReturnsAttributeFromRepository(): void + { + $adminId = 1; + $attributeId = 2; + + $admin = $this->createMock(Administrator::class); + $admin->method('getId')->willReturn($adminId); + + $definition = $this->createMock(AdminAttributeDefinition::class); + $definition->method('getId')->willReturn($attributeId); + + $attribute = new AdminAttributeValue($definition, $admin, 'value'); + + $this->repository->expects($this->once()) + ->method('findOneByAdminIdAndAttributeId') + ->with($adminId, $attributeId) + ->willReturn($attribute); + + $result = $this->subject->getAdminAttribute($adminId, $attributeId); + + $this->assertSame($attribute, $result); + } + + public function testGetAdminAttributeReturnsNullIfNotFound(): void + { + $this->repository->expects($this->once()) + ->method('findOneByAdminIdAndAttributeId') + ->willReturn(null); + + $result = $this->subject->getAdminAttribute(1, 2); + + $this->assertNull($result); + } + + public function testDeleteCallsRemoveOnRepository(): void + { + $attribute = $this->createMock(AdminAttributeValue::class); + + $this->repository->expects($this->once()) + ->method('remove') + ->with($attribute); + + $this->subject->delete($attribute); + } +} diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php new file mode 100644 index 00000000..11c3f378 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php @@ -0,0 +1,96 @@ +createMock(EntityManagerInterface::class); + $hashGenerator = $this->createMock(HashGenerator::class); + + $dto = new CreateAdministratorDto( + loginName: 'admin', + password: 'securepass', + email: 'admin@example.com', + isSuperUser: true + ); + + $hashGenerator->expects($this->once()) + ->method('createPasswordHash') + ->with('securepass') + ->willReturn('hashed_pass'); + + $entityManager->expects($this->once())->method('persist'); + $entityManager->expects($this->once())->method('flush'); + + $manager = new AdministratorManager($entityManager, $hashGenerator); + $admin = $manager->createAdministrator($dto); + + $this->assertEquals('admin', $admin->getLoginName()); + $this->assertEquals('admin@example.com', $admin->getEmail()); + $this->assertTrue($admin->isSuperUser()); + $this->assertEquals('hashed_pass', $admin->getPasswordHash()); + } + + public function testUpdateAdministrator(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $hashGenerator = $this->createMock(HashGenerator::class); + + $admin = new Administrator(); + $admin->setLoginName('old'); + $admin->setEmail('old@example.com'); + $admin->setSuperUser(false); + $admin->setPasswordHash('old_hash'); + + $dto = new UpdateAdministratorDto( + administratorId: 1, + loginName: 'new', + password: 'newpass', + email: 'new@example.com', + superAdmin: true + ); + + $hashGenerator->expects($this->once()) + ->method('createPasswordHash') + ->with('newpass') + ->willReturn('new_hash'); + + $entityManager->expects($this->once())->method('flush'); + + $manager = new AdministratorManager($entityManager, $hashGenerator); + $manager->updateAdministrator($admin, $dto); + + $this->assertEquals('new', $admin->getLoginName()); + $this->assertEquals('new@example.com', $admin->getEmail()); + $this->assertTrue($admin->isSuperUser()); + $this->assertEquals('new_hash', $admin->getPasswordHash()); + } + + public function testDeleteAdministrator(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $hashGenerator = $this->createMock(HashGenerator::class); + + $admin = $this->createMock(Administrator::class); + + $entityManager->expects($this->once())->method('remove')->with($admin); + $entityManager->expects($this->once())->method('flush'); + + $manager = new AdministratorManager($entityManager, $hashGenerator); + $manager->deleteAdministrator($admin); + + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php new file mode 100644 index 00000000..44072452 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -0,0 +1,49 @@ +createMock(AdministratorRepository::class); + $adminRepo->expects(self::once()) + ->method('findOneByLoginCredentials') + ->with('admin', 'wrong') + ->willReturn(null); + + $tokenRepo = $this->createMock(AdministratorTokenRepository::class); + $tokenRepo->expects(self::never())->method('save'); + + $manager = new SessionManager($tokenRepo, $adminRepo); + + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Not authorized'); + + $manager->createSession('admin', 'wrong'); + } + + public function testDeleteSessionCallsRemove(): void + { + $token = $this->createMock(AdministratorToken::class); + + $tokenRepo = $this->createMock(AdministratorTokenRepository::class); + $tokenRepo->expects(self::once()) + ->method('remove') + ->with($token); + + $adminRepo = $this->createMock(AdministratorRepository::class); + + $manager = new SessionManager($tokenRepo, $adminRepo); + $manager->deleteSession($token); + } +} diff --git a/tests/Unit/Domain/Model/Messaging/MessageTest.php b/tests/Unit/Domain/Messaging/Model/MessageTest.php similarity index 79% rename from tests/Unit/Domain/Model/Messaging/MessageTest.php rename to tests/Unit/Domain/Messaging/Model/MessageTest.php index 92ec6245..0201f08b 100644 --- a/tests/Unit/Domain/Model/Messaging/MessageTest.php +++ b/tests/Unit/Domain/Messaging/Model/MessageTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Model\Messaging; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Model; use DateTime; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Messaging\Message; -use PhpList\Core\Domain\Model\Messaging\Message\MessageContent; -use PhpList\Core\Domain\Model\Messaging\Message\MessageFormat; -use PhpList\Core\Domain\Model\Messaging\Message\MessageMetadata; -use PhpList\Core\Domain\Model\Messaging\Message\MessageOptions; -use PhpList\Core\Domain\Model\Messaging\Message\MessageSchedule; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Interfaces\Identity; -use PhpList\Core\Domain\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; +use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; +use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; +use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; use PHPUnit\Framework\TestCase; class MessageTest extends TestCase diff --git a/tests/Unit/Domain/Model/Messaging/SubscriberListTest.php b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php similarity index 93% rename from tests/Unit/Domain/Model/Messaging/SubscriberListTest.php rename to tests/Unit/Domain/Messaging/Model/SubscriberListTest.php index 8b7afa5b..e62dfc22 100644 --- a/tests/Unit/Domain/Model/Messaging/SubscriberListTest.php +++ b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Model\Messaging; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Model; use DateTime; use Doctrine\Common\Collections\Collection; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Model\Subscription\Subscription; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Model\Subscription; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Repository/Messaging/MessageRepositoryTest.php b/tests/Unit/Domain/Messaging/Repository/MessageRepositoryTest.php similarity index 86% rename from tests/Unit/Domain/Repository/Messaging/MessageRepositoryTest.php rename to tests/Unit/Domain/Messaging/Repository/MessageRepositoryTest.php index 067ecb73..c593a9ef 100644 --- a/tests/Unit/Domain/Repository/Messaging/MessageRepositoryTest.php +++ b/tests/Unit/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository\Messaging; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Repository; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; -use PhpList\Core\Domain\Repository\Messaging\MessageRepository; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Unit/Domain/Repository/Messaging/SubscriberListRepositoryTest.php b/tests/Unit/Domain/Messaging/Repository/SubscriberListRepositoryTest.php similarity index 81% rename from tests/Unit/Domain/Repository/Messaging/SubscriberListRepositoryTest.php rename to tests/Unit/Domain/Messaging/Repository/SubscriberListRepositoryTest.php index f1f6a56b..f7808e2b 100644 --- a/tests/Unit/Domain/Repository/Messaging/SubscriberListRepositoryTest.php +++ b/tests/Unit/Domain/Messaging/Repository/SubscriberListRepositoryTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository\Messaging; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Repository; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php new file mode 100644 index 00000000..564bd34d --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php @@ -0,0 +1,168 @@ +createMock(TemplateRepository::class); + $this->formatBuilder = $this->createMock(MessageFormatBuilder::class); + $this->scheduleBuilder = $this->createMock(MessageScheduleBuilder::class); + $this->contentBuilder = $this->createMock(MessageContentBuilder::class); + $this->optionsBuilder = $this->createMock(MessageOptionsBuilder::class); + + $this->builder = new MessageBuilder( + $templateRepository, + $this->formatBuilder, + $this->scheduleBuilder, + $this->contentBuilder, + $this->optionsBuilder + ); + } + + private function createRequest(): CreateMessageDto + { + return new CreateMessageDto( + content: new MessageContentDto( + subject: '', + text: '', + textMessage: '', + footer: '' + ), + format: new MessageFormatDto( + htmlFormated: false, + sendFormat: 'text', + formatOptions: [] + ), + metadata: new MessageMetadataDto( + status: 'draft' + ), + options: new MessageOptionsDto( + fromField: '', + toField: null, + replyTo: null, + userSelection: null + ), + schedule: new MessageScheduleDto( + embargo: '', + repeatInterval: null, + repeatUntil: null, + requeueInterval: null, + requeueUntil: null + ), + templateId: 0 + ); + } + + private function mockBuildCalls(CreateMessageDto $createMessageDto): void + { + $this->formatBuilder->expects($this->once()) + ->method('build') + ->with($createMessageDto->format) + ->willReturn($this->createMock(Message\MessageFormat::class)); + + $this->scheduleBuilder->expects($this->once()) + ->method('build') + ->with($createMessageDto->schedule) + ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class)); + + $this->contentBuilder->expects($this->once()) + ->method('build') + ->with($createMessageDto->content) + ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class)); + + $this->optionsBuilder->expects($this->once()) + ->method('build') + ->with($createMessageDto->options) + ->willReturn($this->createMock(Message\MessageOptions::class)); + } + + public function testBuildsNewMessage(): void + { + $request = $this->createRequest(); + $admin = $this->createMock(Administrator::class); + $context = new MessageContext($admin); + + $this->mockBuildCalls($request); + + $this->builder->build($request, $context); + } + + public function testThrowsExceptionOnInvalidRequest(): void + { + $this->expectException(Error::class); + + $this->builder->build( + $this->createMock(CreateMessageDto::class), + new MessageContext($this->createMock(Administrator::class)) + ); + } + + public function testThrowsExceptionOnInvalidContext(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->builder->build($this->createMock(CreateMessageDto::class), new \stdClass()); + } + + public function testUpdatesExistingMessage(): void + { + $request = $this->createRequest(); + $admin = $this->createMock(Administrator::class); + $existingMessage = $this->createMock(Message::class); + $context = new MessageContext($admin, $existingMessage); + + $this->mockBuildCalls($request); + + $existingMessage + ->expects($this->once()) + ->method('setFormat') + ->with($this->isInstanceOf(Message\MessageFormat::class)); + $existingMessage + ->expects($this->once()) + ->method('setSchedule') + ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class)); + $existingMessage + ->expects($this->once()) + ->method('setContent') + ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class)); + $existingMessage + ->expects($this->once()) + ->method('setOptions') + ->with($this->isInstanceOf(Message\MessageOptions::class)); + $existingMessage->expects($this->once())->method('setTemplate')->with(null); + + $result = $this->builder->build($request, $context); + + $this->assertSame($existingMessage, $result); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php new file mode 100644 index 00000000..2b1aa771 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php @@ -0,0 +1,45 @@ +builder = new MessageContentBuilder(); + } + + public function testBuildsMessageContentSuccessfully(): void + { + $dto = new MessageContentDto( + subject: 'Test Subject', + text: 'Full text content', + textMessage: 'Short text version', + footer: 'Footer text' + ); + + $messageContent = $this->builder->build($dto); + + $this->assertSame('Test Subject', $messageContent->getSubject()); + $this->assertSame('Full text content', $messageContent->getText()); + $this->assertSame('Short text version', $messageContent->getTextMessage()); + $this->assertSame('Footer text', $messageContent->getFooter()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->build($invalidDto); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php new file mode 100644 index 00000000..8d9320a0 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php @@ -0,0 +1,38 @@ +builder = new MessageFormatBuilder(); + } + + public function testBuildsMessageFormatSuccessfully(): void + { + $dto = new MessageFormatDto(htmlFormated: true, sendFormat: 'html', formatOptions: ['html', 'text']); + $messageFormat = $this->builder->build($dto); + + $this->assertSame(true, $messageFormat->isHtmlFormatted()); + $this->assertSame('html', $messageFormat->getSendFormat()); + $this->assertEqualsCanonicalizing(['html', 'text'], $messageFormat->getFormatOptions()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->build($invalidDto); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php new file mode 100644 index 00000000..c6795d29 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php @@ -0,0 +1,45 @@ +builder = new MessageOptionsBuilder(); + } + + public function testBuildsMessageOptionsSuccessfully(): void + { + $dto = new MessageOptionsDto( + fromField: 'info@example.com', + toField: 'user@example.com', + replyTo: 'reply@example.com', + userSelection: 'all-users' + ); + + $messageOptions = $this->builder->build($dto); + + $this->assertSame('info@example.com', $messageOptions->getFromField()); + $this->assertSame('user@example.com', $messageOptions->getToField()); + $this->assertSame('reply@example.com', $messageOptions->getReplyTo()); + $this->assertSame('all-users', $messageOptions->getUserSelection()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->build($invalidDto); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php new file mode 100644 index 00000000..38f04338 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php @@ -0,0 +1,48 @@ +builder = new MessageScheduleBuilder(); + } + + public function testBuildsMessageScheduleSuccessfully(): void + { + $dto = new MessageScheduleDto( + embargo: '2025-04-17T09:00:00+00:00', + repeatInterval: 1440, + repeatUntil: '2025-04-30T00:00:00+00:00', + requeueInterval: 720, + requeueUntil: '2025-04-20T00:00:00+00:00' + ); + + $messageSchedule = $this->builder->build($dto); + + $this->assertSame(1440, $messageSchedule->getRepeatInterval()); + $this->assertEquals(new DateTime('2025-04-30T00:00:00+00:00'), $messageSchedule->getRepeatUntil()); + $this->assertSame(720, $messageSchedule->getRequeueInterval()); + $this->assertEquals(new DateTime('2025-04-20T00:00:00+00:00'), $messageSchedule->getRequeueUntil()); + $this->assertEquals(new DateTime('2025-04-17T09:00:00+00:00'), $messageSchedule->getEmbargo()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->build($invalidDto); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php new file mode 100644 index 00000000..8ee85915 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php @@ -0,0 +1,141 @@ +createMock(MessageRepository::class); + $messageBuilder = $this->createMock(MessageBuilder::class); + $manager = new MessageManager($messageRepository, $messageBuilder); + + $format = new MessageFormatDto(true, 'html', ['html']); + $schedule = new MessageScheduleDto( + embargo: '2025-04-17T09:00:00+00:00', + repeatInterval: 60 * 24, + repeatUntil: '2025-04-30T00:00:00+00:00', + requeueInterval: 60 * 12, + requeueUntil: '2025-04-20T00:00:00+00:00', + ); + $metadata = new MessageMetadataDto('draft'); + $content = new MessageContentDto('Subject', 'Full text', 'Short text', 'Footer'); + $options = new MessageOptionsDto('from@example.com', 'to@example.com', 'reply@example.com', 'all-users'); + + $request = new CreateMessageDto( + content: $content, + format: $format, + metadata: $metadata, + options: $options, + schedule: $schedule, + templateId: 0 + ); + + $authUser = $this->createMock(Administrator::class); + + $expectedMessage = $this->createMock(Message::class); + $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class); + $expectedMetadata = $this->createMock(Message\MessageMetadata::class); + + $expectedContent->method('getSubject')->willReturn('Subject'); + $expectedMetadata->method('getStatus')->willReturn('draft'); + + $expectedMessage->method('getContent')->willReturn($expectedContent); + $expectedMessage->method('getMetadata')->willReturn($expectedMetadata); + + $messageBuilder->expects($this->once()) + ->method('build') + ->with($request, $this->anything()) + ->willReturn($expectedMessage); + + $messageRepository->expects($this->once()) + ->method('save') + ->with($expectedMessage); + + $message = $manager->createMessage($request, $authUser); + + $this->assertSame('Subject', $message->getContent()->getSubject()); + $this->assertSame('draft', $message->getMetadata()->getStatus()); + } + + public function testUpdateMessageReturnsUpdatedMessage(): void + { + $messageRepository = $this->createMock(MessageRepository::class); + $messageBuilder = $this->createMock(MessageBuilder::class); + $manager = new MessageManager($messageRepository, $messageBuilder); + + $format = new MessageFormatDto(false, 'text', ['text']); + $schedule = new MessageScheduleDto( + embargo: '2025-04-17T09:00:00+00:00', + repeatInterval: 0, + repeatUntil: '2025-04-30T00:00:00+00:00', + requeueInterval: 0, + requeueUntil: '2025-04-20T00:00:00+00:00', + ); + $metadata = new MessageMetadataDto('draft'); + $content = new MessageContentDto( + 'Updated Subject', + 'Updated Full text', + 'Updated Short text', + 'Updated Footer' + ); + $options = new MessageOptionsDto( + 'newfrom@example.com', + 'newto@example.com', + 'newreply@example.com', + 'active-users' + ); + + $updateRequest = new UpdateMessageDto( + messageId: 1, + content: $content, + format: $format, + metadata: $metadata, + options: $options, + schedule: $schedule, + templateId: 2 + ); + + $authUser = $this->createMock(Administrator::class); + + $existingMessage = $this->createMock(Message::class); + $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class); + $expectedMetadata = $this->createMock(Message\MessageMetadata::class); + + $expectedContent->method('getSubject')->willReturn('Updated Subject'); + $expectedMetadata->method('getStatus')->willReturn('draft'); + + $existingMessage->method('getContent')->willReturn($expectedContent); + $existingMessage->method('getMetadata')->willReturn($expectedMetadata); + + $messageBuilder->expects($this->once()) + ->method('build') + ->with($updateRequest, $this->anything()) + ->willReturn($existingMessage); + + $messageRepository->expects($this->once()) + ->method('save') + ->with($existingMessage); + + $message = $manager->updateMessage($updateRequest, $existingMessage, $authUser); + + $this->assertSame('Updated Subject', $message->getContent()->getSubject()); + $this->assertSame('draft', $message->getMetadata()->getStatus()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php new file mode 100644 index 00000000..bde3569a --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php @@ -0,0 +1,88 @@ +templateImageRepository = $this->createMock(TemplateImageRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new TemplateImageManager( + $this->templateImageRepository, + $this->entityManager + ); + } + + public function testCreateImagesFromImagePaths(): void + { + $template = $this->createMock(Template::class); + + $this->entityManager->expects($this->exactly(2)) + ->method('persist') + ->with($this->isInstanceOf(TemplateImage::class)); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $images = $this->manager->createImagesFromImagePaths(['image1.jpg', 'image2.png'], $template); + + $this->assertCount(2, $images); + foreach ($images as $image) { + $this->assertInstanceOf(TemplateImage::class, $image); + } + } + + public function testGuessMimeType(): void + { + $reflection = new \ReflectionClass($this->manager); + $method = $reflection->getMethod('guessMimeType'); + + $this->assertSame('image/jpeg', $method->invoke($this->manager, 'photo.jpg')); + $this->assertSame('image/png', $method->invoke($this->manager, 'picture.png')); + $this->assertSame('application/octet-stream', $method->invoke($this->manager, 'file.unknownext')); + } + + public function testExtractAllImages(): void + { + $html = '' . + '' . + '' . + '' . + 'Download' . + '' . + ''; + + $result = $this->manager->extractAllImages($html); + + $this->assertIsArray($result); + $this->assertContains('image1.jpg', $result); + $this->assertContains('https://example.com/image2.png', $result); + } + + public function testDeleteTemplateImage(): void + { + $templateImage = $this->createMock(TemplateImage::class); + + $this->templateImageRepository->expects($this->once()) + ->method('remove') + ->with($templateImage); + + $this->manager->delete($templateImage); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php new file mode 100644 index 00000000..fbbb4831 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php @@ -0,0 +1,91 @@ +templateRepository = $this->createMock(TemplateRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $this->templateImageManager = $this->createMock(TemplateImageManager::class); + $this->templateLinkValidator = $this->createMock(TemplateLinkValidator::class); + $this->templateImageValidator = $this->createMock(TemplateImageValidator::class); + + $this->manager = new TemplateManager( + $this->templateRepository, + $entityManager, + $this->templateImageManager, + $this->templateLinkValidator, + $this->templateImageValidator + ); + } + + public function testCreateTemplateSuccessfully(): void + { + $request = new CreateTemplateDto( + title: 'Test Template', + content: 'Content', + text: 'Plain text', + fileContent: null, + shouldCheckLinks: true, + shouldCheckImages: true, + shouldCheckExternalImages: true + ); + + $this->templateLinkValidator->expects($this->once()) + ->method('validate') + ->with($request->content, $this->anything()); + + $this->templateImageManager->expects($this->once()) + ->method('extractAllImages') + ->with($request->content) + ->willReturn([]); + + $this->templateImageValidator->expects($this->once()) + ->method('validate') + ->with([], $this->anything()); + + $this->templateRepository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Template::class)); + + $this->templateImageManager->expects($this->once()) + ->method('createImagesFromImagePaths') + ->with([], $this->isInstanceOf(Template::class)); + + $template = $this->manager->create($request); + + $this->assertSame('Test Template', $template->getTitle()); + } + + public function testDeleteTemplate(): void + { + $template = $this->createMock(Template::class); + + $this->templateRepository->expects($this->once()) + ->method('remove') + ->with($template); + + $this->manager->delete($template); + } +} diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php new file mode 100644 index 00000000..88af2c8c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php @@ -0,0 +1,87 @@ +httpClient = $this->createMock(ClientInterface::class); + $this->validator = new TemplateImageValidator($this->httpClient); + } + + public function testThrowsExceptionIfValueIsNotArray(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->validator->validate('not-an-array'); + } + + public function testValidatesFullUrls(): void + { + $context = (new ValidationContext())->set('checkImages', true); + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/not-a-url/'); + + $this->validator->validate(['not-a-url', 'https://valid.url/image.jpg'], $context); + } + + public function testValidatesExistenceWithHttp200(): void + { + $context = (new ValidationContext())->set('checkExternalImages', true); + + $this->httpClient->expects($this->once()) + ->method('request') + ->with('HEAD', 'https://example.com/image.jpg') + ->willReturn(new Response(200)); + + $this->validator->validate(['https://example.com/image.jpg'], $context); + + $this->assertTrue(true); + } + + public function testValidatesExistenceWithHttp404(): void + { + $context = (new ValidationContext())->set('checkExternalImages', true); + + $this->httpClient->expects($this->once()) + ->method('request') + ->with('HEAD', 'https://example.com/missing.jpg') + ->willReturn(new Response(404)); + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/does not exist/'); + + $this->validator->validate(['https://example.com/missing.jpg'], $context); + } + + public function testValidatesExistenceThrowsHttpException(): void + { + $context = (new ValidationContext())->set('checkExternalImages', true); + + $this->httpClient->expects($this->once()) + ->method('request') + ->willThrowException(new Exception('Connection failed')); + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/could not be validated/'); + + $this->validator->validate(['https://example.com/broken.jpg'], $context); + } +} diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php new file mode 100644 index 00000000..d0ab6566 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php @@ -0,0 +1,66 @@ +validator = new TemplateLinkValidator(); + } + + public function testSkipsValidationIfNotString(): void + { + $context = (new ValidationContext())->set('checkLinks', true); + + $this->validator->validate(['not', 'a', 'string'], $context); + + $this->assertTrue(true); + } + + public function testSkipsValidationIfCheckLinksIsFalse(): void + { + $context = (new ValidationContext())->set('checkLinks', false); + + $this->validator->validate('Broken link', $context); + + $this->assertTrue(true); + } + + public function testValidatesInvalidLinks(): void + { + $context = (new ValidationContext())->set('checkLinks', true); + + $html = 'Broken'; + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/invalid-link/'); + + $this->validator->validate($html, $context); + } + + public function testAllowsValidLinksAndPlaceholders(): void + { + $context = (new ValidationContext())->set('checkLinks', true); + + $html = '' . + 'Valid Link' . + 'Valid Link' . + 'Email Link' . + 'Placeholder' . + ''; + + $this->validator->validate($html, $context); + + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Domain/Model/Subscription/SubscriberTest.php b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php similarity index 95% rename from tests/Unit/Domain/Model/Subscription/SubscriberTest.php rename to tests/Unit/Domain/Subscription/Model/SubscriberTest.php index f61c3192..5a60c5de 100644 --- a/tests/Unit/Domain/Model/Subscription/SubscriberTest.php +++ b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Model\Subscription; +namespace PhpList\Core\Tests\Unit\Domain\Subscription\Model; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Model\Subscription\Subscription; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Model\Subscription; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Model/Subscription/SubscriptionTest.php b/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php similarity index 86% rename from tests/Unit/Domain/Model/Subscription/SubscriptionTest.php rename to tests/Unit/Domain/Subscription/Model/SubscriptionTest.php index 5cdf3c03..d6abfe09 100644 --- a/tests/Unit/Domain/Model/Subscription/SubscriptionTest.php +++ b/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Model\Subscription; +namespace PhpList\Core\Tests\Unit\Domain\Subscription\Model; use DateTime; -use PhpList\Core\Domain\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Model\Subscription\Subscription; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Model\Subscription; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Repository/Subscription/SubscriberRepositoryTest.php b/tests/Unit/Domain/Subscription/Repository/SubscriberRepositoryTest.php similarity index 76% rename from tests/Unit/Domain/Repository/Subscription/SubscriberRepositoryTest.php rename to tests/Unit/Domain/Subscription/Repository/SubscriberRepositoryTest.php index 03048703..6cfde555 100644 --- a/tests/Unit/Domain/Repository/Subscription/SubscriberRepositoryTest.php +++ b/tests/Unit/Domain/Subscription/Repository/SubscriberRepositoryTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository\Subscription; +namespace PhpList\Core\Tests\Unit\Domain\Subscription\Repository; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\TestCase; /** @@ -24,7 +25,7 @@ protected function setUp(): void $entityManager = $this->createMock(EntityManager::class); $classMetadata = $this->createMock(ClassMetadata::class); - $classMetadata->name = 'PhpList\Core\Domain\Model\Subscription\Subscriber'; + $classMetadata->name = Subscriber::class; $this->subject = new SubscriberRepository($entityManager, $classMetadata); } diff --git a/tests/Unit/Domain/Repository/Subscription/SubscriptionRepositoryTest.php b/tests/Unit/Domain/Subscription/Repository/SubscriptionRepositoryTest.php similarity index 76% rename from tests/Unit/Domain/Repository/Subscription/SubscriptionRepositoryTest.php rename to tests/Unit/Domain/Subscription/Repository/SubscriptionRepositoryTest.php index fff307c0..00242293 100644 --- a/tests/Unit/Domain/Repository/Subscription/SubscriptionRepositoryTest.php +++ b/tests/Unit/Domain/Subscription/Repository/SubscriptionRepositoryTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Repository\Subscription; +namespace PhpList\Core\Tests\Unit\Domain\Subscription\Repository; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; -use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository; +use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; use PHPUnit\Framework\TestCase; /** @@ -24,7 +25,7 @@ protected function setUp(): void $entityManager = $this->createMock(EntityManager::class); $classMetadata = $this->createMock(ClassMetadata::class); - $classMetadata->name = 'PhpList\Core\Domain\Model\Subscription\Subscription'; + $classMetadata->name = Subscription::class; $this->subject = new SubscriptionRepository($entityManager, $classMetadata); } diff --git a/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php new file mode 100644 index 00000000..bc5a6f18 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php @@ -0,0 +1,151 @@ +createMock(SubscriberAttributeDefinitionRepository::class); + $manager = new AttributeDefinitionManager($repository); + + $dto = new AttributeDefinitionDto( + name: 'Country', + type: 'checkbox', + listOrder: 1, + defaultValue: 'US', + required: true, + tableName: 'user_attribute' + ); + + $repository->expects($this->once()) + ->method('findOneByName') + ->with('Country') + ->willReturn(null); + + $repository->expects($this->once())->method('save'); + + $attribute = $manager->create($dto); + + $this->assertInstanceOf(SubscriberAttributeDefinition::class, $attribute); + $this->assertSame('Country', $attribute->getName()); + $this->assertSame('checkbox', $attribute->getType()); + $this->assertSame(1, $attribute->getListOrder()); + $this->assertSame('US', $attribute->getDefaultValue()); + $this->assertTrue($attribute->isRequired()); + $this->assertSame('user_attribute', $attribute->getTableName()); + } + + public function testCreateThrowsWhenAttributeAlreadyExists(): void + { + $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); + $manager = new AttributeDefinitionManager($repository); + + $dto = new AttributeDefinitionDto( + name: 'Country', + type: 'checkbox', + listOrder: 1, + defaultValue: 'US', + required: true, + tableName: 'user_attribute' + ); + + $existing = $this->createMock(SubscriberAttributeDefinition::class); + + $repository->expects($this->once()) + ->method('findOneByName') + ->with('Country') + ->willReturn($existing); + + $this->expectException(AttributeDefinitionCreationException::class); + + $manager->create($dto); + } + + public function testUpdateAttributeDefinition(): void + { + $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); + $manager = new AttributeDefinitionManager($repository); + + $attribute = new SubscriberAttributeDefinition(); + $attribute->setName('Old'); + + $dto = new AttributeDefinitionDto( + name: 'New', + type: 'text', + listOrder: 5, + defaultValue: 'Canada', + required: false, + tableName: 'custom_attrs' + ); + + $repository->expects($this->once()) + ->method('findOneByName') + ->with('New') + ->willReturn(null); + + $repository->expects($this->once())->method('save')->with($attribute); + + $updated = $manager->update($attribute, $dto); + + $this->assertSame('New', $updated->getName()); + $this->assertSame('text', $updated->getType()); + $this->assertSame(5, $updated->getListOrder()); + $this->assertSame('Canada', $updated->getDefaultValue()); + $this->assertFalse($updated->isRequired()); + $this->assertSame('custom_attrs', $updated->getTableName()); + } + + public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void + { + $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); + $manager = new AttributeDefinitionManager($repository); + + $dto = new AttributeDefinitionDto( + name: 'Existing', + type: 'text', + listOrder: 5, + defaultValue: 'Canada', + required: false, + tableName: 'custom_attrs' + ); + + $current = new SubscriberAttributeDefinition(); + $current->setName('Old'); + + $other = $this->createMock(SubscriberAttributeDefinition::class); + $other->method('getId')->willReturn(999); + + $repository->expects($this->once()) + ->method('findOneByName') + ->with('Existing') + ->willReturn($other); + + $this->expectException(AttributeDefinitionCreationException::class); + + $manager->update($current, $dto); + } + + public function testDeleteAttributeDefinition(): void + { + $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); + $manager = new AttributeDefinitionManager($repository); + + $attribute = new SubscriberAttributeDefinition(); + + $repository->expects($this->once())->method('remove')->with($attribute); + + $manager->delete($attribute); + + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberAttributeManagerTest.php new file mode 100644 index 00000000..be81b2e7 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriberAttributeManagerTest.php @@ -0,0 +1,111 @@ +createMock(SubscriberAttributeValueRepository::class); + + $subscriberAttrRepo->expects(self::once()) + ->method('findOneBySubscriberAndAttribute') + ->with($subscriber, $definition) + ->willReturn(null); + + $subscriberAttrRepo->expects(self::once()) + ->method('save') + ->with(self::callback(function (SubscriberAttributeValue $attr) { + return $attr->getValue() === 'US'; + })); + + $manager = new SubscriberAttributeManager($subscriberAttrRepo); + $attribute = $manager->createOrUpdate($subscriber, $definition, 'US'); + + self::assertInstanceOf(SubscriberAttributeValue::class, $attribute); + self::assertSame('US', $attribute->getValue()); + } + + public function testUpdateExistingSubscriberAttribute(): void + { + $subscriber = new Subscriber(); + $definition = new SubscriberAttributeDefinition(); + $existing = new SubscriberAttributeValue($definition, $subscriber); + $existing->setValue('Old'); + + $subscriberAttrRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $subscriberAttrRepo->expects(self::once()) + ->method('findOneBySubscriberAndAttribute') + ->with($subscriber, $definition) + ->willReturn($existing); + + $subscriberAttrRepo->expects(self::once()) + ->method('save') + ->with($existing); + + $manager = new SubscriberAttributeManager($subscriberAttrRepo); + $result = $manager->createOrUpdate($subscriber, $definition, 'Updated'); + + self::assertSame('Updated', $result->getValue()); + } + + public function testCreateFailsWhenValueAndDefaultAreNull(): void + { + $subscriber = new Subscriber(); + $definition = new SubscriberAttributeDefinition(); + + $subscriberAttrRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $subscriberAttrRepo->method('findOneBySubscriberAndAttribute')->willReturn(null); + + $manager = new SubscriberAttributeManager($subscriberAttrRepo); + + $this->expectException(SubscriberAttributeCreationException::class); + $this->expectExceptionMessage('Value is required'); + + $manager->createOrUpdate($subscriber, $definition, null); + } + + public function testGetSubscriberAttribute(): void + { + $subscriberAttrRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $expected = new SubscriberAttributeValue(new SubscriberAttributeDefinition(), new Subscriber()); + + $subscriberAttrRepo->expects(self::once()) + ->method('findOneBySubscriberIdAndAttributeId') + ->with(5, 10) + ->willReturn($expected); + + $manager = new SubscriberAttributeManager($subscriberAttrRepo); + $result = $manager->getSubscriberAttribute(5, 10); + + self::assertSame($expected, $result); + } + + public function testDeleteSubscriberAttribute(): void + { + $subscriberAttrRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $attribute = $this->createMock(SubscriberAttributeValue::class); + + $subscriberAttrRepo->expects(self::once()) + ->method('remove') + ->with($attribute); + + $manager = new SubscriberAttributeManager($subscriberAttrRepo); + $manager->delete($attribute); + + self::assertTrue(true); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php new file mode 100644 index 00000000..c3aeef68 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php @@ -0,0 +1,147 @@ +attributeManagerMock = $this->createMock(SubscriberAttributeManager::class); + $this->subscriberRepositoryMock = $this->createMock(SubscriberRepository::class); + $this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class); + + $this->subject = new SubscriberCsvExporter( + $this->attributeManagerMock, + $this->subscriberRepositoryMock, + $this->attributeDefinitionRepositoryMock + ); + } + + public function testExportToCsvWithFilterReturnsStreamedResponse(): void + { + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getId')->willReturn(1); + $subscriber1->method('getEmail')->willReturn('test@example.com'); + $subscriber1->method('isConfirmed')->willReturn(true); + $subscriber1->method('isBlacklisted')->willReturn(false); + $subscriber1->method('hasHtmlEmail')->willReturn(true); + $subscriber1->method('isDisabled')->willReturn(false); + $subscriber1->method('getExtraData')->willReturn('Some data'); + + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getId')->willReturn(2); + $subscriber2->method('getEmail')->willReturn('another@example.com'); + $subscriber2->method('isConfirmed')->willReturn(false); + $subscriber2->method('isBlacklisted')->willReturn(true); + $subscriber2->method('hasHtmlEmail')->willReturn(false); + $subscriber2->method('isDisabled')->willReturn(true); + $subscriber2->method('getExtraData')->willReturn('More data'); + + $filter = new SubscriberFilter(1); + + $this->subscriberRepositoryMock + ->expects($this->exactly(2)) + ->method('getFilteredAfterId') + ->willReturnOnConsecutiveCalls( + [$subscriber1, $subscriber2], + [] + ); + + $attributeDefinition = $this->createMock(SubscriberAttributeDefinition::class); + $attributeDefinition->method('getName')->willReturn('first_name'); + $attributeDefinition->method('getId')->willReturn(1); + + $this->attributeDefinitionRepositoryMock + ->method('findAll') + ->willReturn([$attributeDefinition]); + + $attributeValue1 = $this->createMock(SubscriberAttributeValue::class); + $attributeValue1->method('getValue')->willReturn('John'); + + $attributeValue2 = $this->createMock(SubscriberAttributeValue::class); + $attributeValue2->method('getValue')->willReturn('Jane'); + + $this->attributeManagerMock + ->method('getSubscriberAttribute') + ->willReturnMap([ + [1, 1, $attributeValue1], + [2, 1, $attributeValue2], + ]); + + $response = $this->subject->exportToCsv($filter, 2); + $response->sendContent(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('text/csv; charset=utf-8', $response->headers->get('Content-Type')); + $this->assertStringContainsString( + needle: 'attachment; filename=subscribers_export_', + haystack: $response->headers->get('Content-Disposition') + ); + } + + public function testExportToCsvWithoutFilterCreatesDefaultFilter(): void + { + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getId')->willReturn(1); + $subscriber1->method('getEmail')->willReturn('test@example.com'); + $subscriber1->method('isConfirmed')->willReturn(true); + $subscriber1->method('isBlacklisted')->willReturn(false); + $subscriber1->method('hasHtmlEmail')->willReturn(true); + $subscriber1->method('isDisabled')->willReturn(false); + $subscriber1->method('getExtraData')->willReturn('Some data'); + + $this->subscriberRepositoryMock + ->expects($this->exactly(1)) + ->method('getFilteredAfterId') + ->willReturnOnConsecutiveCalls( + [$subscriber1], + [] + ); + + $attributeDefinition = $this->createMock(SubscriberAttributeDefinition::class); + $attributeDefinition->method('getName')->willReturn('first_name'); + $attributeDefinition->method('getId')->willReturn(1); + + $this->attributeDefinitionRepositoryMock + ->method('findAll') + ->willReturn([$attributeDefinition]); + + $attributeValue1 = $this->createMock(SubscriberAttributeValue::class); + $attributeValue1->method('getValue')->willReturn('John'); + + $this->attributeManagerMock + ->method('getSubscriberAttribute') + ->willReturnMap([ + [1, 1, $attributeValue1], + ]); + + $response = $this->subject->exportToCsv(); + $response->sendContent(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('text/csv; charset=utf-8', $response->headers->get('Content-Type')); + $this->assertStringContainsString( + needle: 'attachment; filename=subscribers_export_', + haystack: $response->headers->get('Content-Disposition') + ); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php new file mode 100644 index 00000000..8f65e5ae --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -0,0 +1,189 @@ +subscriberManagerMock = $this->createMock(SubscriberManager::class); + $this->attributeManagerMock = $this->createMock(SubscriberAttributeManager::class); + $this->subscriptionManagerMock = $this->createMock(SubscriptionManager::class); + $this->subscriberRepositoryMock = $this->createMock(SubscriberRepository::class); + $this->csvImporterMock = $this->createMock(CsvImporter::class); + $this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class); + + $this->subject = new SubscriberCsvImporter( + $this->subscriberManagerMock, + $this->attributeManagerMock, + $this->subscriptionManagerMock, + $this->subscriberRepositoryMock, + $this->csvImporterMock, + $this->attributeDefinitionRepositoryMock + ); + } + + public function testImportFromCsvCreatesNewSubscribers(): void + { + $csvContent = "email,confirmed,html_email,blacklisted,disabled,extra_data,first_name\n"; + $csvContent .= "test@example.com,1,1,0,0,\"Some extra data\",John\n"; + $csvContent .= "another@example.com,0,0,1,1,\"More data\",Jane\n"; + + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test'); + file_put_contents($tempFile, $csvContent); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $attributeDefinition = $this->createMock(SubscriberAttributeDefinition::class); + $attributeDefinition->method('getName')->willReturn('first_name'); + $attributeDefinition->method('getId')->willReturn(1); + + $this->attributeDefinitionRepositoryMock + ->method('findOneByName') + ->with('first_name') + ->willReturn($attributeDefinition); + + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getId')->willReturn(1); + + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getId')->willReturn(2); + + $this->subscriberRepositoryMock + ->method('findOneByEmail') + ->willReturn(null); + + $importDto1 = new ImportSubscriberDto( + email: 'test@example.com', + confirmed: true, + blacklisted: false, + htmlEmail: true, + disabled: false, + ); + $importDto1->extraData = 'Some extra data'; + $importDto1->extraAttributes = ['first_name' => 'John']; + + $importDto2 = new ImportSubscriberDto( + email: 'another@example.com', + confirmed: false, + blacklisted: true, + htmlEmail: false, + disabled: true + ); + $importDto2->extraData = 'More data'; + $importDto2->extraAttributes = ['first_name' => 'Jane']; + + $this->csvImporterMock + ->method('import') + ->with($tempFile) + ->willReturn([ + 'valid' => [$importDto1, $importDto2], + 'errors' => [] + ]); + + $this->subscriberManagerMock + ->expects($this->exactly(2)) + ->method('createFromImport') + ->willReturnOnConsecutiveCalls($subscriber1, $subscriber2); + + $this->attributeManagerMock + ->expects($this->exactly(2)) + ->method('createOrUpdate') + ->withConsecutive( + [$subscriber1, $attributeDefinition, 'John'], + [$subscriber2, $attributeDefinition, 'Jane'] + ); + + $options = new SubscriberImportOptions(); + $result = $this->subject->importFromCsv($uploadedFile, $options); + + $this->assertSame(2, $result['created']); + $this->assertSame(0, $result['updated']); + $this->assertSame(0, $result['skipped']); + $this->assertEmpty($result['errors']); + + unlink($tempFile); + } + + public function testImportFromCsvUpdatesExistingSubscribers(): void + { + $csvContent = "email,confirmed,html_email,blacklisted,disabled,extra_data\n"; + $csvContent .= "existing@example.com,1,1,0,0,\"Updated data\"\n"; + + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test'); + file_put_contents($tempFile, $csvContent); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $existingSubscriber = $this->createMock(Subscriber::class); + $existingSubscriber->method('getId')->willReturn(1); + + $this->subscriberRepositoryMock + ->method('findOneByEmail') + ->with('existing@example.com') + ->willReturn($existingSubscriber); + + $updatedSubscriber = $this->createMock(Subscriber::class); + + $importDto = new ImportSubscriberDto( + email: 'existing@example.com', + confirmed: true, + blacklisted: false, + htmlEmail: true, + disabled: false, + ); + $importDto->extraData = 'Updated data'; + $importDto->extraAttributes = []; + + $this->csvImporterMock + ->method('import') + ->with($tempFile) + ->willReturn([ + 'valid' => [$importDto], + 'errors' => [] + ]); + + $this->subscriberManagerMock + ->expects($this->once()) + ->method('updateFromImport') + ->with($existingSubscriber, $importDto) + ->willReturn($updatedSubscriber); + + $options = new SubscriberImportOptions(updateExisting: true); + $result = $this->subject->importFromCsv($uploadedFile, $options); + + $this->assertSame(0, $result['created']); + $this->assertSame(1, $result['updated']); + $this->assertSame(0, $result['skipped']); + $this->assertEmpty($result['errors']); + + unlink($tempFile); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberListManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberListManagerTest.php new file mode 100644 index 00000000..0d607256 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriberListManagerTest.php @@ -0,0 +1,77 @@ +subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $this->manager = new SubscriberListManager($this->subscriberListRepository); + } + + public function testCreateSubscriberList(): void + { + $request = new CreateSubscriberListDto( + name: 'New List', + isPublic: true, + listPosition: 3, + description: 'Description' + ); + + $admin = new Administrator(); + + $this->subscriberListRepository + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(SubscriberList::class)); + + $result = $this->manager->createSubscriberList($request, $admin); + + $this->assertSame('New List', $result->getName()); + $this->assertSame('Description', $result->getDescription()); + $this->assertSame(3, $result->getListPosition()); + $this->assertTrue($result->isPublic()); + $this->assertSame($admin, $result->getOwner()); + } + + public function testGetPaginated(): void + { + $list = new SubscriberList(); + $this->subscriberListRepository + ->expects($this->once()) + ->method('getAfterId') + ->willReturn([$list]); + + $result = $this->manager->getPaginated(0, 1); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertSame($list, $result[0]); + } + + public function testDeleteSubscriberList(): void + { + $subscriberList = new SubscriberList(); + + $this->subscriberListRepository + ->expects($this->once()) + ->method('remove') + ->with($subscriberList); + + $this->manager->delete($subscriberList); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php new file mode 100644 index 00000000..ef233294 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php @@ -0,0 +1,44 @@ +createMock(SubscriberRepository::class); + $emMock = $this->createMock(EntityManagerInterface::class); + $repoMock + ->expects($this->once()) + ->method('save') + ->with($this->callback(function (Subscriber $sub): bool { + return $sub->getEmail() === 'foo@bar.com' + && $sub->isConfirmed() === false + && $sub->isBlacklisted() === false + && $sub->hasHtmlEmail() === true + && $sub->isDisabled() === false; + })); + + $manager = new SubscriberManager($repoMock, $emMock); + + $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: true, htmlEmail: true); + + $result = $manager->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()); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriptionManagerTest.php new file mode 100644 index 00000000..3199476b --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriptionManagerTest.php @@ -0,0 +1,113 @@ +subscriptionRepository = $this->createMock(SubscriptionRepository::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $this->manager = new SubscriptionManager( + $this->subscriptionRepository, + $this->subscriberRepository, + $subscriberListRepository + ); + } + + public function testCreateSubscriptionWhenSubscriberExists(): void + { + $email = 'test@example.com'; + $subscriber = new Subscriber(); + $list = new SubscriberList(); + + $this->subscriberRepository->method('findOneBy')->with(['email' => $email])->willReturn($subscriber); + $this->subscriptionRepository->method('findOneBySubscriberListAndSubscriber')->willReturn(null); + $this->subscriptionRepository->expects($this->once())->method('save'); + + $subscriptions = $this->manager->createSubscriptions($list, [$email]); + + $this->assertCount(1, $subscriptions); + $this->assertInstanceOf(Subscription::class, $subscriptions[0]); + } + + public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void + { + $this->expectException(SubscriptionCreationException::class); + $this->expectExceptionMessage('Subscriber does not exists.'); + + $list = new SubscriberList(); + + $this->subscriberRepository->method('findOneBy')->willReturn(null); + + $this->manager->createSubscriptions($list, ['missing@example.com']); + } + + public function testDeleteSubscriptionSuccessfully(): void + { + $email = 'user@example.com'; + $subscriberList = $this->createMock(SubscriberList::class); + $subscriberList->method('getId')->willReturn(1); + $subscription = new Subscription(); + + $this->subscriptionRepository + ->method('findOneBySubscriberEmailAndListId') + ->with($subscriberList->getId(), $email) + ->willReturn($subscription); + + $this->subscriptionRepository->expects($this->once())->method('remove')->with($subscription); + + $this->manager->deleteSubscriptions($subscriberList, [$email]); + } + + public function testDeleteSubscriptionSkipsNotFound(): void + { + $email = 'missing@example.com'; + $subscriberList = $this->createMock(SubscriberList::class); + $subscriberList->method('getId')->willReturn(1); + + $this->subscriptionRepository + ->method('findOneBySubscriberEmailAndListId') + ->willReturn(null); + + $this->manager->deleteSubscriptions($subscriberList, [$email]); + + $this->addToAssertionCount(1); + } + + public function testGetSubscriberListMembersReturnsList(): void + { + $subscriberList = $this->createMock(SubscriberList::class); + $subscriberList->method('getId')->willReturn(1); + $subscriber = new Subscriber(); + + $this->subscriberRepository + ->method('getSubscribersBySubscribedListId') + ->with($subscriberList->getId()) + ->willReturn([$subscriber]); + + $result = $this->manager->getSubscriberListMembers($subscriberList); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertInstanceOf(Subscriber::class, $result[0]); + } +} diff --git a/tests/Unit/Security/AuthenticationTest.php b/tests/Unit/Security/AuthenticationTest.php index 86883763..0dad0bec 100644 --- a/tests/Unit/Security/AuthenticationTest.php +++ b/tests/Unit/Security/AuthenticationTest.php @@ -4,9 +4,9 @@ namespace PhpList\Core\Tests\Unit\Security; -use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Identity\AdministratorToken; -use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; +use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Security\Authentication; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase;