diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 0ce9e85a..38549c12 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -13,13 +13,13 @@ reviews: instructions: | You are reviewing PHP domain-layer code. Enforce domain purity, with a relaxed policy for DynamicListAttr: - - ❌ Do not allow persistence or transaction side effects here for *normal* domain models. - - Flag ANY usage of Doctrine persistence APIs on regular domain entities, especially: + - ❌ Do not allow, flag ANY DB write / finalization: - `$entityManager->flush(...)`, `$this->entityManager->flush(...)` - - `$em->persist(...)`, `$em->remove(...)` - - `$em->beginTransaction()`, `$em->commit()`, `$em->rollback()` + - `$em->beginTransaction()`, `$em->commit()`, `$em->rollback()`, `$em->transactional(...)` + - `$em->getConnection()->executeStatement(...)` for DML/DDL (INSERT/UPDATE/DELETE/ALTER/...) - ✅ Accessing Doctrine *metadata*, *schema manager*, or *read-only schema info* is acceptable - as long as it does not modify state or perform writes. + as long as it does not modify state or perform writes. Accessing Doctrine *persistence APIs* + persist, remove, etc.) is acceptable, allow scheduling changes in the UnitOfWork (no DB writes) - ✅ **Relaxed rule for DynamicListAttr-related code**: - DynamicListAttr is a special case dealing with dynamic tables/attrs. diff --git a/composer.json b/composer.json index e696c132..aba1a887 100644 --- a/composer.json +++ b/composer.json @@ -79,7 +79,12 @@ "ext-imap": "*", "tatevikgr/rss-feed": "dev-main", "ext-pdo": "*", - "ezyang/htmlpurifier": "^4.19" + "ezyang/htmlpurifier": "^4.19", + "ext-libxml": "*", + "ext-gd": "*", + "ext-curl": "*", + "ext-fileinfo": "*", + "setasign/fpdf": "^1.8" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index b3b8a8d4..9738d792 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -7,7 +7,7 @@ */Migrations/* - + @@ -41,12 +41,12 @@ - + - + diff --git a/config/PhpCodeSniffer/ruleset.xml b/config/PhpCodeSniffer/ruleset.xml index 7541e406..03d41b43 100644 --- a/config/PhpCodeSniffer/ruleset.xml +++ b/config/PhpCodeSniffer/ruleset.xml @@ -103,6 +103,10 @@ - + + + + + diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 41c9a20b..8d3af216 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -25,12 +25,22 @@ parameters: env(DATABASE_PREFIX): 'phplist_' list_table_prefix: '%%env(LIST_TABLE_PREFIX)%%' env(LIST_TABLE_PREFIX): 'listattr_' + app.dev_version: '%%env(APP_DEV_VERSION)%%' + env(APP_DEV_VERSION): '0' + app.dev_email: '%%env(APP_DEV_EMAIL)%%' + env(APP_DEV_EMAIL): 'dev@dev.com' + app.powered_by_phplist: '%%env(APP_POWERED_BY_PHPLIST)%%' + env(APP_POWERED_BY_PHPLIST): '0' + app.preference_page_show_private_lists: '%%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%%' + env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS): '0' + app.rest_api_domain: '%%env(REST_API_DOMAIN)%%' + env(REST_API_DOMAIN): 'https://example.com/api/v2' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' env(MAILER_FROM): 'noreply@phplist.com' app.mailer_dsn: '%%env(MAILER_DSN)%%' - env(MAILER_DSN): 'null://null' + env(MAILER_DSN): 'null://null' # set local_domain on transport app.confirmation_url: '%%env(CONFIRMATION_URL)%%' env(CONFIRMATION_URL): 'https://example.com/subscriber/confirm/' app.subscription_confirmation_url: '%%env(SUBSCRIPTION_CONFIRMATION_URL)%%' @@ -89,3 +99,42 @@ parameters: env(MESSAGING_MAX_PROCESS_TIME): '600' messaging.max_mail_size: '%%env(MAX_MAILSIZE)%%' env(MAX_MAILSIZE): '209715200' + messaging.default_message_age: '%%env(DEFAULT_MESSAGEAGE)%%' + env(DEFAULT_MESSAGEAGE): '691200' + messaging.use_manual_text_part: '%%env(USE_MANUAL_TEXT_PART)%%' + env(USE_MANUAL_TEXT_PART): '0' + messaging.blacklist_grace_time: '%%env(MESSAGING_BLACKLIST_GRACE_TIME)%%' + env(MESSAGING_BLACKLIST_GRACE_TIME): '600' + messaging.google_sender_id: '%%env(GOOGLE_SENDERID)%%' + env(GOOGLE_SENDERID): '' + messaging.use_amazon_ses: '%%env(USE_AMAZONSES)%%' + env(USE_AMAZONSES): '0' + messaging.use_precedence_header: '%%env(USE_PRECEDENCE_HEADER)%%' + env(USE_PRECEDENCE_HEADER): '0' + messaging.embed_external_images: '%%env(EMBEDEXTERNALIMAGES)%%' + env(EMBEDEXTERNALIMAGES): '0' + messaging.embed_uploaded_images: '%%env(EMBEDUPLOADIMAGES)%%' + env(EMBEDUPLOADIMAGES): '0' + messaging.external_image_max_age: '%%env(EXTERNALIMAGE_MAXAGE)%%' + env(EXTERNALIMAGE_MAXAGE): '0' + messaging.external_image_timeout: '%%env(EXTERNALIMAGE_TIMEOUT)%%' + env(EXTERNALIMAGE_TIMEOUT): '30' + messaging.external_image_max_size: '%%env(EXTERNALIMAGE_MAXSIZE)%%' + env(EXTERNALIMAGE_MAXSIZE): '204800' + messaging.forward_alternative_content: '%%env(FORWARD_ALTERNATIVE_CONTENT)%%' + env(FORWARD_ALTERNATIVE_CONTENT): '0' + messaging.email_text_credits: '%%env(EMAILTEXTCREDITS)%%' + env(EMAILTEXTCREDITS): '0' + messaging.always_add_user_track: '%%env(ALWAYS_ADD_USERTRACK)%%' + env(ALWAYS_ADD_USERTRACK): '1' + + phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%' + env(PHPLIST_UPLOADIMAGES_DIR): 'images' + phplist.editor_images_dir: '%%env(FCKIMAGES_DIR)%%' + env(FCKIMAGES_DIR): 'uploadimages' + phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%' + env(PUBLIC_SCHEMA): 'https' + phplist.attachment_download_url: '%%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)%%' + env(PHPLIST_ATTACHMENT_DOWNLOAD_URL): 'https://example.com/download/' + phplist.attachment_repository_path: '%%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%%' + env(PHPLIST_ATTACHMENT_REPOSITORY_PATH): '/tmp' diff --git a/config/services/builders.yml b/config/services/builders.yml index 10a994a4..8a386408 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -4,22 +4,30 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder: - autowire: true - autoconfigure: true + PhpList\Core\Domain\: + resource: '../../src/Domain/*/Service/Builder/*' - PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder: - autowire: true - autoconfigure: true + # Concrete mail constructors + PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder: ~ + PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder: ~ - PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder: - autowire: true - autoconfigure: true + # Two EmailBuilder services with different constructors injected + Core.EmailBuilder.system: + class: PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder + arguments: + $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder' + $googleSenderId: '%messaging.google_sender_id%' + $useAmazonSes: '%messaging.use_amazon_ses%' + $usePrecedenceHeader: '%messaging.use_precedence_header%' + $devVersion: '%app.dev_version%' + $devEmail: '%app.dev_email%' - PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: - autowire: true - autoconfigure: true + Core.EmailBuilder.campaign: + class: PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder + arguments: + $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder' + $googleSenderId: '%messaging.google_sender_id%' + $useAmazonSes: '%messaging.use_amazon_ses%' + $usePrecedenceHeader: '%messaging.use_precedence_header%' + $devVersion: '%app.dev_version%' + $devEmail: '%app.dev_email%' diff --git a/config/services/managers.yml b/config/services/managers.yml index 75475459..83059bc9 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,53 +4,11 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager: - autowire: true - autoconfigure: true + PhpList\Core\Domain\: + resource: '../../src/Domain/*/Service/Manager/*' + exclude: '../../src/Domain/*/Service/Manager/Builder/*' - PhpList\Core\Domain\Identity\Service\SessionManager: - 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\Identity\Service\PasswordManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: - 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\Subscription\Service\Manager\AttributeDefinitionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrManager: - autowire: true - autoconfigure: true + PhpList\Core\Bounce\Service\Manager\BounceManager: ~ Doctrine\DBAL\Schema\AbstractSchemaManager: factory: ['@doctrine.dbal.default_connection', 'createSchemaManager'] @@ -62,55 +20,3 @@ services: arguments: $dbPrefix: '%database_prefix%' $dynamicListTablePrefix: '%list_table_prefix%' - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: - autowire: true - autoconfigure: true - - PhpList\Core\Bounce\Service\Manager\BounceManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager: - autowire: true - autoconfigure: true diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 110129d5..eb4b5d7a 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -5,36 +5,15 @@ services: resource: '../../src/Domain/Messaging/MessageHandler' tags: [ 'messenger.message_handler' ] - PhpList\Core\Domain\Messaging\MessageHandler\SubscriberConfirmationMessageHandler: + # Register Subscription message handlers (e.g., DynamicTableMessageHandler) + PhpList\Core\Domain\Subscription\MessageHandler\: autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - arguments: - $confirmationUrl: '%app.confirmation_url%' - - PhpList\Core\Domain\Messaging\MessageHandler\AsyncEmailMessageHandler: - autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - - PhpList\Core\Domain\Messaging\MessageHandler\PasswordResetMessageHandler: - autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - arguments: - $passwordResetUrl: '%app.password_reset_url%' - - PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler: - autowire: true - autoconfigure: true + resource: '../../src/Domain/Subscription/MessageHandler' tags: [ 'messenger.message_handler' ] PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: - autowire: true - arguments: - $maxMailSize: '%messaging.max_mail_size%' - - PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler: autowire: true autoconfigure: true - tags: [ 'messenger.message_handler' ] + arguments: + $campaignEmailBuilder: '@Core.EmailBuilder.campaign' + $systemEmailBuilder: '@Core.EmailBuilder.system' diff --git a/config/services/parameters.yml b/config/services/parameters.yml index ebf1d99b..18aa6ccf 100644 --- a/config/services/parameters.yml +++ b/config/services/parameters.yml @@ -1,4 +1,9 @@ parameters: + # Flattened parameters for direct DI usage (Symfony does not support dot access into arrays) + app.config.message_from_address: 'news@example.com' + app.config.default_message_age: 15768000 + + # Keep original grouped array for legacy/config-provider usage app.config: message_from_address: 'news@example.com' admin_address: 'admin@example.com' diff --git a/config/services/providers.yml b/config/services/providers.yml index b7b66be8..481b23a3 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -7,12 +7,6 @@ services: arguments: $config: '%app.config%' - PhpList\Core\Domain\Common\IspRestrictionsProvider: - autowire: true - autoconfigure: true - arguments: - $confPath: '%app.phplist_isp_conf_path%' - PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider: autowire: true PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: @@ -30,3 +24,6 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberAttributeChangeSetProvider: autowire: true + + PhpList\Core\Domain\Common\IspRestrictionsProvider: + autowire: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index ea1f0001..37b31c18 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -22,6 +22,11 @@ services: arguments: - PhpList\Core\Domain\Configuration\Model\EventLog + PhpList\Core\Domain\Configuration\Repository\UrlCacheRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\UrlCache + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository @@ -145,3 +150,13 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\MessageData + + PhpList\Core\Domain\Messaging\Repository\AttachmentRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Attachment + + PhpList\Core\Domain\Messaging\Repository\MessageAttachmentRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\MessageAttachment diff --git a/config/services/resolvers.yml b/config/services/resolvers.yml index 99c08356..6dfab328 100644 --- a/config/services/resolvers.yml +++ b/config/services/resolvers.yml @@ -13,3 +13,27 @@ services: PhpList\Core\Bounce\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + + PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\ConfirmationUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver: + autowire: true + autoconfigure: true + + _instanceof: + PhpList\Core\Domain\Configuration\Service\Placeholder\PlaceholderValueResolverInterface: + tags: ['phplist.placeholder_resolver'] + PhpList\Core\Domain\Configuration\Service\Placeholder\PatternValueResolverInterface: + tags: [ 'phplist.pattern_resolver' ] + PhpList\Core\Domain\Configuration\Service\Placeholder\SupportingPlaceholderResolverInterface: + tags: [ 'phplist.supporting_placeholder_resolver' ] diff --git a/config/services/services.yml b/config/services/services.yml index 65ede6b7..c07ee0bb 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,4 +1,9 @@ services: + _defaults: + autowire: true + autoconfigure: true + public: false + PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: autowire: true autoconfigure: true @@ -12,9 +17,6 @@ services: PhpList\Core\Domain\Messaging\Service\EmailService: autowire: true autoconfigure: true - arguments: - $defaultFromEmail: '%app.mailer_from%' - $bounceEmail: '%imap_bounce.email%' PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: autowire: true @@ -43,6 +45,59 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Common\OnceCacheGuard: + autowire: true + autoconfigure: true + + # Html to Text converter used by mail constructors + PhpList\Core\Domain\Common\Html2Text: + autowire: true + autoconfigure: true + + # Rewrites relative asset URLs in fetched HTML to absolute ones + PhpList\Core\Domain\Common\HtmlUrlRewriter: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\PdfGenerator: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\AttachmentAdder: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\FileHelper: + autowire: true + autoconfigure: true + + # External image caching/downloading helper used by TemplateImageEmbedder + PhpList\Core\Domain\Common\ExternalImageService: + autowire: true + autoconfigure: true + arguments: + $tempDir: '%kernel.cache_dir%' + # Use literal defaults if parameters are not defined in this environment + $externalImageMaxAge: 0 + $externalImageMaxSize: 204800 + $externalImageTimeout: 30 + + # Embed images from templates and filesystem into HTML emails + PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder: + autowire: true + autoconfigure: true + arguments: + $documentRoot: '%kernel.project_dir%/public' + # Reuse upload_images_dir for editorImagesDir if a dedicated parameter is absent + $editorImagesDir: '%phplist.upload_images_dir%' + $embedExternalImages: '%messaging.embed_external_images%' + $embedUploadedImages: '%messaging.embed_uploaded_images%' + $uploadImagesDir: '%phplist.upload_images_dir%' + PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer: autowire: true autoconfigure: true @@ -120,9 +175,13 @@ services: autoconfigure: true public: true - PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + PhpList\Core\Domain\Configuration\Service\MessagePlaceholderProcessor: autowire: true autoconfigure: true + arguments: + $placeholderResolvers: !tagged_iterator phplist.placeholder_resolver + $patternResolvers: !tagged_iterator phplist.pattern_resolver + $supportingResolvers: !tagged_iterator phplist.supporting_placeholder_resolver PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder: autowire: true @@ -133,3 +192,34 @@ services: arguments: [ '@cache.app' ] Psr\SimpleCache\CacheInterface: '@cache.app.simple' + + PhpList\Core\Domain\Messaging\Service\MailSizeChecker: + autowire: true + autoconfigure: true + arguments: + $maxMailSize: '%messaging.max_mail_size%' + + # Loads and normalises message data for campaigns + PhpList\Core\Domain\Messaging\Service\MessageDataLoader: + autowire: true + autoconfigure: true + arguments: + $defaultMessageAge: '%app.config.default_message_age%' + + # Common helpers required by precache/message building + PhpList\Core\Domain\Common\TextParser: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\RemotePageFetcher: + autowire: true + autoconfigure: true + + # Pre-caches base message content (HTML/Text/template) for campaigns + PhpList\Core\Domain\Messaging\Service\MessagePrecacheService: + autowire: true + autoconfigure: true + arguments: + $useManualTextPart: '%messaging.use_manual_text_part%' + $uploadImageDir: '%phplist.upload_images_dir%' + $publicSchema: '%phplist.public_schema%' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 12e03eee..3237ea39 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,6 +7,11 @@ colors="true" bootstrap="vendor/autoload.php" > + + + tests + + diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 02ca7140..fee9ed2f 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -738,6 +738,78 @@ Thank you. Value must be an AttributeTypeEnum or string. __Value must be an AttributeTypeEnum or string. + + Campaign started + __Campaign started + + + phplist has started sending the campaign with subject %s + __phplist has started sending the campaign with subject %s + + + phplist has started sending the campaign with subject %subject% + __phplist has started sending the campaign with subject %subject% + + + Unsubscribe + __Unsubscribe + + + This link + __This link + + + Confirm + __Confirm + + + Update preferences + __Update preferences + + + Sorry, you are not subscribed to any of our newsletters with this email address. + __Sorry, you are not subscribed to any of our newsletters with this email address. + + + This message contains attachments that can be viewed with a webbrowser + __This message contains attachments that can be viewed with a webbrowser + + + Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + + + Add us to your address book + __Add us to your address book + + + phpList system error + __phpList system error + + + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. + __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. + + + failed to open attachment (%remoteFile%) to add to campaign %campaignId% + __failed to open attachment (%remoteFile%) to add to campaign %campaignId% + + + Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + + + Attachment %remoteFile% does not exist + __Attachment %remoteFile% does not exist + + + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. + __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. + + + Location + __Location + diff --git a/src/Bounce/Service/LockService.php b/src/Bounce/Service/LockService.php index c3948c1f..a875959c 100644 --- a/src/Bounce/Service/LockService.php +++ b/src/Bounce/Service/LockService.php @@ -34,9 +34,6 @@ public function __construct( $this->maxWaitCycles = $maxWaitCycles; } - /** - * @SuppressWarnings("BooleanArgumentFlag") - */ public function acquirePageLock( string $page, bool $force = false, diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 902092f6..0b3a8c5e 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -8,8 +8,7 @@ use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; -use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; class LinkTrackService { @@ -39,7 +38,7 @@ public function isExtractAndSaveLinksApplicable(): bool * @return LinkTrack[] The saved LinkTrack entities * @throws MissingMessageIdException */ - public function extractAndSaveLinks(MessageContent $content, int $userId, ?int $messageId = null): array + public function extractAndSaveLinks(MessagePrecacheDto $content, int $userId, ?int $messageId = null): array { if (!$this->isExtractAndSaveLinksApplicable()) { return []; @@ -49,10 +48,10 @@ public function extractAndSaveLinks(MessageContent $content, int $userId, ?int $ throw new MissingMessageIdException(); } - $links = $this->extractLinksFromHtml($content->getText() ?? ''); + $links = $this->extractLinksFromHtml($content->content ?? ''); - if ($content->getFooter() !== null) { - $links = array_merge($links, $this->extractLinksFromHtml($content->getFooter())); + if ($content->htmlFooter) { + $links = array_merge($links, $this->extractLinksFromHtml($content->htmlFooter)); } $links = array_unique($links); diff --git a/src/Domain/Common/ExternalImageService.php b/src/Domain/Common/ExternalImageService.php new file mode 100644 index 00000000..c2ef3e17 --- /dev/null +++ b/src/Domain/Common/ExternalImageService.php @@ -0,0 +1,222 @@ +externalCacheDir = $this->tempDir . '/external_cache'; + } + + public function getFromCache(string $filename, int $messageId): ?string + { + $cacheFile = $this->generateLocalFileName($filename, $messageId); + + if (!is_file($cacheFile) || filesize($cacheFile) <= 64) { + return null; + } + + $content = file_get_contents($cacheFile); + if ($content === false) { + return null; + } + + return base64_encode($content); + } + + public function cache($filename, $messageId): bool + { + if (!$this->isCacheableUrl($filename)) { + return false; + } + + if (!$this->ensureCacheDirectory()) { + return false; + } + + $this->removeOldFilesInCache(); + + $cacheFileName = $this->generateLocalFileName($filename, $messageId); + + if (!file_exists($cacheFileName)) { + $cacheFileContent = null; + + if (function_exists('curl_init')) { + $cacheFileContent = $this->downloadUsingCurl($filename); + } + + if ($cacheFileContent === null) { + $cacheFileContent = $this->downloadUsingFileGetContent($filename); + } + + if ($this->externalImageMaxSize && (strlen($cacheFileContent) > $this->externalImageMaxSize)) { + $cacheFileContent = 'MAX_SIZE'; + } + + $this->writeCacheFile($cacheFileName, $cacheFileContent); + } + + return $this->isValidCacheFile($cacheFileName); + } + + private function removeOldFilesInCache(): void + { + // phpcs:ignore Generic.PHP.NoSilencedErrors + $extCacheDirHandle = @opendir($this->externalCacheDir); + if (!$this->externalImageMaxAge || !$extCacheDirHandle) { + return; + } + + while (true) { + // phpcs:ignore Generic.PHP.NoSilencedErrors + $cacheFile = @readdir($extCacheDirHandle); + + if ($cacheFile === false) { + break; + } + // todo: make sure that this is what we need + if (!str_starts_with($cacheFile, '.')) { + // phpcs:ignore Generic.PHP.NoSilencedErrors + $cfmt = @filemtime($this->externalCacheDir . '/' . $cacheFile); + + if (is_numeric($cfmt) && ($cfmt > 0) && ((time() - $cfmt) > $this->externalImageMaxAge)) { + // phpcs:ignore Generic.PHP.NoSilencedErrors + @unlink($this->externalCacheDir . '/' . $cacheFile); + } + } + } + // phpcs:ignore Generic.PHP.NoSilencedErrors + @closedir($extCacheDirHandle); + } + + private function generateLocalFileName(string $filename, int $messageId): string + { + return $this->externalCacheDir + . '/' + . $messageId + . '_' + . preg_replace([ '~[\.][\.]+~Ui', '~[^\w\.]~Ui',], ['', '_'], $filename); + } + + private function downloadUsingCurl(string $filename): ?string + { + $cURLHandle = curl_init($filename); + + if ($cURLHandle !== false) { + curl_setopt($cURLHandle, CURLOPT_HTTPGET, true); + curl_setopt($cURLHandle, CURLOPT_HEADER, 0); + curl_setopt($cURLHandle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($cURLHandle, CURLOPT_TIMEOUT, $this->externalImageTimeout); + curl_setopt($cURLHandle, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($cURLHandle, CURLOPT_MAXREDIRS, 10); + curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($cURLHandle, CURLOPT_FAILONERROR, true); + + $cacheFileContent = curl_exec($cURLHandle); + + $cURLErrNo = curl_errno($cURLHandle); + $cURLInfo = curl_getinfo($cURLHandle); + + curl_close($cURLHandle); + + if ($cURLErrNo != 0) { + $cacheFileContent = 'CURL_ERROR_' . $cURLErrNo; + } + if ($cURLInfo['http_code'] >= 400) { + $cacheFileContent = 'HTTP_CODE_' . $cURLInfo['http_code']; + } + } + + return $cacheFileContent ?? null; + } + + private function downloadUsingFileGetContent(string $filename): string + { + $remoteURLContext = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => $this->externalImageTimeout, + 'max_redirects' => '10', + ] + ]); + + $cacheFileContent = file_get_contents($filename, false, $remoteURLContext); + if ($cacheFileContent === false) { + $cacheFileContent = 'FGC_ERROR'; + } + + return $cacheFileContent; + } + + private function isCacheableUrl($filename): bool + { + if (!(str_starts_with($filename, 'http')) + || str_contains($filename, '://' . $this->configProvider->getValue(ConfigOption::Website) . '/') + ) { + return false; + } + + return true; + } + + private function ensureCacheDirectory(): bool + { + + if (!file_exists($this->externalCacheDir)) { + // phpcs:ignore Generic.PHP.NoSilencedErrors + @mkdir($this->externalCacheDir); + } + + if (!file_exists($this->externalCacheDir) || !is_writable($this->externalCacheDir)) { + return false; + } + + return true; + } + + private function isValidCacheFile(string $cacheFileName): bool + { + // phpcs:ignore Generic.PHP.NoSilencedErrors + if (file_exists($cacheFileName) && (@filesize($cacheFileName) > 64)) { + return true; + } + + return false; + } + + private function writeCacheFile(string $cacheFileName, $content): void + { + // phpcs:ignore Generic.PHP.NoSilencedErrors + $bytes = @file_put_contents($cacheFileName, $content, LOCK_EX); + + if ($bytes === false) { + $this->logger->error('Cache file write failed', ['file' => $cacheFileName]); + return; + } + + $expected = strlen($content); + if ($bytes !== $expected) { + $this->logger->error('Cache file partial write', [ + 'file' => $cacheFileName, + 'expected' => $expected, + 'written' => $bytes, + ]); + } + } +} diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php new file mode 100644 index 00000000..5dab8b05 --- /dev/null +++ b/src/Domain/Common/FileHelper.php @@ -0,0 +1,61 @@ +]*>(.*?)<\/script\s*>/is', '', $text); + $text = preg_replace('/]*>(.*?)<\/style\s*>/is', '', $text); + + $text = preg_replace( + "/]*href=([\"\'])(.*)\\1[^>]*>(.*)<\/a>/Umis", + "[URLTEXT]\\3[ENDURLTEXT][LINK]\\2[ENDLINK]\n", + $text + ); + $text = preg_replace('/(.*?)<\/b\s*>/is', '*\\1*', $text); + $text = preg_replace('/(.*?)<\/h[\d]\s*>/is', "**\\1**\n", $text); + $text = preg_replace('/(.*?)<\/i\s*>/is', '/\\1/', $text); + $text = preg_replace('/<\/tr\s*?>/i', "<\/tr>\n\n", $text); + $text = preg_replace('/<\/p\s*?>/i', "<\/p>\n\n", $text); + $text = preg_replace('/]*?>/i', "
\n", $text); + $text = preg_replace('/]*?\/>/i', "\n", $text); + $text = preg_replace('/ $fullMatch) { + $linkText = $links[1][$matchIndex]; + $linkUrl = $links[2][$matchIndex]; + // check if the text linked is a repetition of the URL + if (trim($linkText) == trim($linkUrl) || + 'https://'.trim($linkText) == trim($linkUrl) || + 'http://'.trim($linkText) == trim($linkUrl) + ) { + $linkReplace = $linkUrl; + } else { + //# if link is an anchor only, take it out + if (str_starts_with($linkUrl, '#')) { + $linkReplace = $linkText; + } else { + $linkReplace = $linkText.' <'.$linkUrl.'>'; + } + } + $text = str_replace($fullMatch, $linkReplace, $text); + } + $text = preg_replace( + "/]*>(.*?)<\/a>/is", + '[URLTEXT]\\2[ENDURLTEXT][LINK]\\1[ENDLINK]', + $text, + 500 + ); + + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $text = preg_replace('/###NL###/', "\n", $text); + $text = preg_replace("/\n /", "\n", $text); + $text = preg_replace("/\t/", ' ', $text); + + // reduce whitespace + while (preg_match('/ /', $text)) { + $text = preg_replace('/ /', ' ', $text); + } + while (preg_match("/\n\s*\n\s*\n/", $text)) { + $text = preg_replace("/\n\s*\n\s*\n/", "\n\n", $text); + } + $wordWrap = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP; + + return wordwrap($text, (int) $wordWrap); + } +} diff --git a/src/Domain/Common/HtmlUrlRewriter.php b/src/Domain/Common/HtmlUrlRewriter.php new file mode 100644 index 00000000..27edb56f --- /dev/null +++ b/src/Domain/Common/HtmlUrlRewriter.php @@ -0,0 +1,208 @@ +
' . $html . '
'; + $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + $xpath = new DOMXPath($dom); + + // Attributes to rewrite + $attrMap = [ + '//*[@src]' => 'src', + '//*[@href]' => 'href', + '//*[@action]' => 'action', + '//*[@background]' => 'background', + ]; + + foreach ($attrMap as $query => $attr) { + foreach ($xpath->query($query) as $node) { + /** @var DOMElement $node */ + $val = $node->getAttribute($attr); + $node->setAttribute($attr, $this->absolutizeUrl($val, $baseUrl)); + } + } + + // srcset needs special handling (multiple candidates) + foreach ($xpath->query('//*[@srcset]') as $node) { + /** @var DOMElement $node */ + $node->setAttribute('srcset', $this->rewriteSrcset($node->getAttribute('srcset'), $baseUrl)); + } + + // 2) Rewrite inline +
X
+ '; + + $base = 'https://ex.am/dir/level/page.html'; + $out = $this->rewriter->addAbsoluteResources($html, $base); + + $this->assertMatchesRegularExpression( + '~url\((["\']?)https://ex\.am/dir/img/bg\.png\1\)~', + $out + ); + + $this->assertMatchesRegularExpression( + '~@import\s+(?:url\()?(["\']?)https://ex\.am/css/reset\.css\1\)?~', + $out + ); + + $this->assertMatchesRegularExpression( + '~@import\s+(?:url\()?(["\']?)https://ex\.am/dir/level/css/theme\.css\1\)?~', + $out + ); + + $this->assertMatchesRegularExpression( + '~url\((["\']?)https://ex\.am/dir/level/icons/ico\.svg\1\)~', + $out + ); + } + + public function testAbsolutizeUrlDirectlyCoversDotSegmentsAndPort(): void + { + $base = 'http://example.com:8080/a/b/c/'; + + $this->assertSame( + 'http://example.com:8080/a/b/img.png', + $this->rewriter->absolutizeUrl('../img.png', $base) + ); + + $this->assertSame( + 'http://example.com:8080/a/b/c/d/e.png?x=1#top', + $this->rewriter->absolutizeUrl('d/./e.png?x=1#top', $base) + ); + } +} diff --git a/tests/Unit/Domain/Common/OnceCacheGuardTest.php b/tests/Unit/Domain/Common/OnceCacheGuardTest.php new file mode 100644 index 00000000..50afa0f9 --- /dev/null +++ b/tests/Unit/Domain/Common/OnceCacheGuardTest.php @@ -0,0 +1,78 @@ +cache = $this->createMock(CacheInterface::class); + } + + public function testFirstTimeReturnsTrueAndSetsKeyWithTtl(): void + { + $key = 'once:key:123'; + $ttl = 60; + + $this->cache->expects($this->once()) + ->method('has') + ->with($key) + ->willReturn(false); + + $this->cache->expects($this->once()) + ->method('set') + ->with($key, true, $ttl) + ->willReturn(true); + + $guard = new OnceCacheGuard($this->cache); + + $this->assertTrue($guard->firstTime($key, $ttl)); + } + + public function testFirstTimeReturnsFalseWhenKeyAlreadyPresent(): void + { + $key = 'once:key:present'; + + $this->cache->expects($this->once()) + ->method('has') + ->with($key) + ->willReturn(true); + + $this->cache->expects($this->never()) + ->method('set'); + + $guard = new OnceCacheGuard($this->cache); + + $this->assertFalse($guard->firstTime($key, 10)); + } + + public function testFirstTimeIgnoresSetFailureAndStillReturnsTrueOnFirstCall(): void + { + $key = 'once:key:set-fails'; + $ttl = 5; + + $this->cache->expects($this->once()) + ->method('has') + ->with($key) + ->willReturn(false); + + // Even if underlying cache set returns false, guard should return true. + $this->cache->expects($this->once()) + ->method('set') + ->with($key, true, $ttl) + ->willReturn(false); + + $guard = new OnceCacheGuard($this->cache); + + $this->assertTrue($guard->firstTime($key, $ttl)); + } +} diff --git a/tests/Unit/Domain/Common/PdfGeneratorTest.php b/tests/Unit/Domain/Common/PdfGeneratorTest.php new file mode 100644 index 00000000..78df55c8 --- /dev/null +++ b/tests/Unit/Domain/Common/PdfGeneratorTest.php @@ -0,0 +1,46 @@ +createPdfBytes($text); + + $this->assertIsString($pdfBytes); + $this->assertNotSame('', $pdfBytes); + + // Must start with a valid PDF header + $this->assertStringStartsWith('%PDF-', $pdfBytes); + + // Should contain EOF marker somewhere near the end + $this->assertNotFalse(strpos($pdfBytes, '%%EOF')); + + // Should be reasonably sized for a minimal 1-page PDF + $this->assertGreaterThan(100, strlen($pdfBytes)); + } + + public function testCreatePdfBytesContainsCreatorMetadataAndSomeText(): void + { + $generator = new PdfGenerator(); + $text = 'Sample text for pdfList PDF'; + + $pdfBytes = $generator->createPdfBytes($text); + + // FPDF stores the Creator metadata; value set to 'phpList' in PdfGenerator + $this->assertNotFalse(strpos($pdfBytes, 'phpList')); + + // The plain text often appears within a text object; ensure at least a fragment is present + $fragment = 'Sample text'; + $this->assertNotFalse(strpos($pdfBytes, $fragment)); + } +} diff --git a/tests/Unit/Domain/Common/RemotePageFetcherTest.php b/tests/Unit/Domain/Common/RemotePageFetcherTest.php new file mode 100644 index 00000000..caa360f0 --- /dev/null +++ b/tests/Unit/Domain/Common/RemotePageFetcherTest.php @@ -0,0 +1,203 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->configProvider = $this->createMock(ConfigProvider::class); + $this->urlCacheRepository = $this->createMock(UrlCacheRepository::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->htmlUrlRewriter = $this->createMock(HtmlUrlRewriter::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + } + + private function createFetcher(int $ttl = 300): RemotePageFetcher + { + return new RemotePageFetcher( + httpClient: $this->httpClient, + cache: $this->cache, + configProvider: $this->configProvider, + urlCacheRepository: $this->urlCacheRepository, + eventLogManager: $this->eventLogManager, + htmlUrlRewriter: $this->htmlUrlRewriter, + entityManager: $this->entityManager, + defaultTtl: $ttl, + ); + } + + public function testReturnsContentFromPsrCacheWhenFresh(): void + { + $url = 'https://example.com/page?x=1&y=2'; + $this->configProvider->method('getValue')->with(ConfigOption::RemoteUrlAppend)->willReturn(''); + + $cached = [ + 'fetched' => time(), + 'content' => '

cached

', + ]; + $this->cache->method('get')->with(md5($url))->willReturn($cached); + + $this->urlCacheRepository->expects($this->never())->method('findByUrlAndLastModified'); + $this->httpClient->expects($this->never())->method('request'); + + $fetcher = $this->createFetcher(); + $result = $fetcher($url, []); + + $this->assertSame('

cached

', $result); + } + + public function testReturnsContentFromDbCacheWhenFresh(): void + { + $url = 'https://ex.org/page'; + $this->configProvider->method('getValue')->with(ConfigOption::RemoteUrlAppend)->willReturn(''); + + $this->cache->method('get')->with(md5($url))->willReturn(null); + + $recent = (new UrlCache()) + ->setUrl($url) + ->setLastModified(time()) + ->setContent('

db

'); + + $this->urlCacheRepository + ->expects($this->once()) + ->method('findByUrlAndLastModified') + ->with($url) + ->willReturn($recent); + + $this->httpClient->expects($this->never())->method('request'); + + $fetcher = $this->createFetcher(); + $result = $fetcher($url, []); + + $this->assertSame('

db

', $result); + } + + public function testFetchesAndCachesWhenNoFreshCache(): void + { + $url = 'https://ex.net/a.html'; + $this->configProvider->method('getValue')->with(ConfigOption::RemoteUrlAppend)->willReturn(''); + + $this->cache->method('get')->with(md5($url))->willReturn(null); + + $this->urlCacheRepository + ->expects($this->atLeast(2)) + ->method('findByUrlAndLastModified') + ->with($this->equalTo($url), $this->logicalOr($this->equalTo(0), $this->isType('int'))) + ->willReturnOnConsecutiveCalls(null, null); + + $this->urlCacheRepository->method('getByUrl')->with($url)->willReturn([]); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getContent')->with(false)->willReturn('

hello

'); + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', $url, $this->arrayHasKey('timeout')) + ->willReturn($response); + + $this->htmlUrlRewriter + ->expects($this->once()) + ->method('addAbsoluteResources') + ->with('

hello

', $url) + ->willReturn('rewritten:

hello

'); + + $this->urlCacheRepository->expects($this->once())->method('persist') + ->with($this->isInstanceOf(UrlCache::class)); + + $this->cache->expects($this->once())->method('set') + ->with(md5($url), $this->callback(function ($v) { + return is_array($v) + && isset($v['fetched'], $v['content']) + && $v['content'] === 'rewritten:

hello

' + && is_int($v['fetched']); + })); + + $this->eventLogManager->expects($this->atLeastOnce())->method('log'); + + $fetcher = $this->createFetcher(); + $result = $fetcher($url, []); + + $this->assertSame('rewritten:

hello

', $result); + } + + public function testHttpFailureReturnsEmptyStringAndNoCacheSet(): void + { + $url = 'https://bad.example/x'; + $this->configProvider->method('getValue')->with(ConfigOption::RemoteUrlAppend)->willReturn(''); + $this->cache->method('get')->with(md5($url))->willReturn(null); + + $this->urlCacheRepository->method('findByUrlAndLastModified')->willReturn(null); + + $this->httpClient->method('request')->willThrowException(new \RuntimeException('fail')); + + $this->cache->expects($this->never())->method('set'); + $this->entityManager->expects($this->never())->method('persist'); + $this->htmlUrlRewriter->expects($this->never())->method('addAbsoluteResources'); + + $fetcher = $this->createFetcher(); + $result = $fetcher($url, []); + + $this->assertSame('', $result); + } + + public function testUrlExpansionAndPlaceholderSubstitution(): void + { + $baseUrl = 'https://site.tld/path'; + + $this->configProvider->method('getValue')->with(ConfigOption::RemoteUrlAppend)->willReturn('a=1&b=2'); + + $this->cache->method('get')->willReturn(null); + + $this->urlCacheRepository->method('findByUrlAndLastModified')->willReturn(null); + $this->urlCacheRepository->method('getByUrl')->willReturn([]); + + // After expansion, the code appends sanitized string directly. Because the URL already + // contains a '?', append will be concatenated without an extra separator. + + // The invoke method replaces placeholders in URL prior to expansion. + $urlWithPlaceholders = $baseUrl . '/[name]?q=[q]&x=1'; + $userData = ['name' => 'John Doe', 'q' => 'a&b', 'password' => 'secret']; + + $response = $this->createMock(ResponseInterface::class); + $response->method('getContent')->with(false)->willReturn('ok'); + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with($this->equalTo('GET'), $this->isType('string'), $this->arrayHasKey('timeout')) + ->willReturn($response); + + $this->htmlUrlRewriter->method('addAbsoluteResources')->willReturnCallback(fn(string $html) => $html); + + $fetcher = $this->createFetcher(); + $result = $fetcher($urlWithPlaceholders, $userData); + + $this->assertSame('ok', $result); + } +} diff --git a/tests/Unit/Domain/Common/TextParserTest.php b/tests/Unit/Domain/Common/TextParserTest.php new file mode 100644 index 00000000..5920c037 --- /dev/null +++ b/tests/Unit/Domain/Common/TextParserTest.php @@ -0,0 +1,69 @@ +parser = new TextParser(); + } + + public function testEmailIsMadeClickable(): void + { + $input = 'Contact me at foo.bar-1@example.co.uk'; + $out = ($this->parser)($input); + + $this->assertSame( + 'Contact me at
', + $out + ); + } + + public function testHttpUrlAutoLinkAndPeriodOutside(): void + { + $input = 'See http://example.com/path.'; + $out = ($this->parser)($input); + + // For non-www URLs, the displayed text is without the scheme + $this->assertSame( + 'See example.com/path.', + $out + ); + } + + public function testWwwAutoLink(): void + { + $input = 'Visit www.google.com/maps'; + $out = ($this->parser)($input); + + $this->assertSame( + 'Visit www.google.com/maps', + $out + ); + } + + public function testNewlinesBecomeBrAndLeadingTrim(): void + { + // leading newline should be trimmed, others converted + $input = "\nLine1\nLine2"; + $out = ($this->parser)($input); + + $this->assertSame("Line1
\nLine2", $out); + } + + public function testParensAndDollarPreserved(): void + { + $input = 'Price is $10 (approx)'; + $out = ($this->parser)($input); + + $this->assertSame('Price is $10 (approx)', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php new file mode 100644 index 00000000..0b7c9b0d --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php @@ -0,0 +1,190 @@ +config = $this->createMock(ConfigProvider::class); + $this->attrRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $this->attrResolver = $this->createMock(AttributeValueResolver::class); + $this->attrRepo->method('getForSubscriber')->willReturn([]); + } + + private function makeUser(string $email = 'user@example.com', string $uid = 'UID123'): Subscriber + { + $u = new Subscriber(); + $u->setEmail($email); + $u->setUniqueId($uid); + return $u; + } + + public function testEnsuresStandardPlaceholdersAndUsertrackInHtmlOnly(): void + { + $user = $this->makeUser(); + $dto = new MessagePrecacheDto(); + + // alwaysAddUserTrack = true + $processor = new MessagePlaceholderProcessor( + config: $this->config, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + placeholderResolvers: [], + patternResolvers: [], + supportingResolvers: [], + alwaysAddUserTrack: true, + ); + + $html = 'Hello'; + $processedHtml = $processor->process( + value: $html, + user: $user, + format: OutputFormat::Html, + messagePrecacheDto: $dto, + campaignId: 42, + forwardedBy: null, + ); + + // FOOTER and SIGNATURE must be inserted before , USERTRACK appended for Html when flag enabled + $this->assertStringContainsString('
[FOOTER] [SIGNATURE] [USERTRACK]', $processedHtml); + + // In Text, FOOTER and SIGNATURE are appended with newlines, no USERTRACK even if flag enabled + $text = 'Hi'; + $processedText = $processor->process( + value: $text, + user: $user, + format: OutputFormat::Text, + messagePrecacheDto: $dto, + ); + + $this->assertStringEndsWith("\n\n[FOOTER]\n[SIGNATURE]", $processedText); + $this->assertStringNotContainsString('[USERTRACK]', $processedText); + } + + public function testBuiltInResolversReplaceEmailUserIdAndConfigValues(): void + { + $user = $this->makeUser('alice@example.com', 'U-999'); + $dto = new MessagePrecacheDto(); + + $this->config->method('getValue')->willReturnCallback( + function (ConfigOption $opt): ?string { + return match ($opt) { + ConfigOption::Website => 'https://site.example', + ConfigOption::Domain => 'example.com', + ConfigOption::OrganisationName => 'ACME Inc', + default => null, + }; + } + ); + + $processor = new MessagePlaceholderProcessor( + config: $this->config, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + placeholderResolvers: [], + patternResolvers: [], + supportingResolvers: [], + alwaysAddUserTrack: false, + ); + + $content = 'Hi [EMAIL], id=[USERID], web=[WEBSITE], dom=[DOMAIN], org=[ORGANIZATION_NAME].'; + $out = $processor->process( + value: $content, + user: $user, + format: OutputFormat::Text, + messagePrecacheDto: $dto, + campaignId: 101, + forwardedBy: 'bob@example.com', + ); + + $this->assertStringContainsString('Hi alice@example.com,', $out); + $this->assertStringContainsString('id=U-999,', $out); + $this->assertStringContainsString('web=https://site.example,', $out); + $this->assertStringContainsString('dom=example.com,', $out); + $this->assertStringContainsString('org=ACME Inc.', $out); + } + + public function testCustomResolversFromIterablesAreApplied(): void + { + $user = $this->makeUser(); + $dto = new MessagePrecacheDto(); + + // Placeholder by name: [CUSTOM] + $customPlaceholder = new class implements PlaceholderValueResolverInterface { + public function name(): string + { + return 'CUSTOM'; + } + public function __invoke(PlaceholderContext $ctx): string + { + return 'XVAL'; + } + }; + + // Pattern resolver: [UPPER:text] + $pattern = new class implements PatternValueResolverInterface { + public function pattern(): string + { + return '/\[UPPER:([^\]]+)]/i'; + } + public function __invoke(PlaceholderContext $ctx, array $matches): string + { + return strtoupper($matches[1]); + } + }; + + // Supporting resolver: for key SUPPORT + $supporting = new class implements SupportingPlaceholderResolverInterface { + public function supports(string $key, PlaceholderContext $ctx): bool + { + return strtoupper($key) === 'SUPPORT'; + } + public function resolve(string $key, PlaceholderContext $ctx): ?string + { + return 'SVAL'; + } + }; + + $processor = new MessagePlaceholderProcessor( + config: $this->config, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + placeholderResolvers: [$customPlaceholder], + patternResolvers: [$pattern], + supportingResolvers: [$supporting], + alwaysAddUserTrack: false, + ); + + $content = 'A [CUSTOM] B [UPPER:abc] C [SUPPORT]'; + $out = $processor->process( + value: $content, + user: $user, + format: OutputFormat::Text, + messagePrecacheDto: $dto, + ); + + $this->assertStringContainsString('A XVAL B ABC C SVAL', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php new file mode 100644 index 00000000..49eb567b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php @@ -0,0 +1,95 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $email = 'user@example.com'): Subscriber + { + $u = new Subscriber(); + $u->setEmail($email); + $u->setUniqueId('UID-123'); + return $u; + } + + public function testName(): void + { + $resolver = new BlacklistUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('BLACKLISTURL', $resolver->name()); + } + + public function testInvokedForHtmlEscapesUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $expectedRaw = 'https://example.com/blacklist.php?a=1&b=2&email=user%40example.com'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($expectedRaw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 1, + ); + + $resolver = new BlacklistUrlValueResolver($this->config, $this->urlBuilder); + $result = $resolver($ctx); + + // In HTML, ampersands must be escaped + $this->assertSame( + 'https://example.com/blacklist.php?a=1&b=2&email=user%40example.com', + $result + ); + } + + public function testInvokedForTextReturnsPlainUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $expectedRaw = 'https://example.com/blacklist.php?email=user%40example.com'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($expectedRaw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new BlacklistUrlValueResolver($this->config, $this->urlBuilder); + $result = $resolver($ctx); + + $this->assertSame($expectedRaw, $result); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php new file mode 100644 index 00000000..7607270b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php @@ -0,0 +1,97 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $email = 'user@example.com'): Subscriber + { + $u = new Subscriber(); + $u->setEmail($email); + $u->setUniqueId('UID-1'); + return $u; + } + + public function testName(): void + { + $resolver = new BlacklistValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('BLACKLIST', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithTranslatedEscapedLabelAndUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $rawUrl = 'https://example.com/blacklist.php?email=user%40example.com&x=1'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($rawUrl); + + // Translator returns a label with characters that require escaping + $this->translator->method('trans') + ->with('Unsubscribe') + ->willReturn('Unsubscribe & more "now" <>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new BlacklistValueResolver($this->config, $this->urlBuilder, $this->translator); + $result = $resolver($ctx); + + $expectedHref = htmlspecialchars($rawUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedLabel = htmlspecialchars('Unsubscribe & more "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expected = '' . $expectedLabel . ''; + + $this->assertSame($expected, $result); + } + + public function testTextReturnsPlainUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $rawUrl = 'https://example.com/blacklist.php?email=user%40example.com'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($rawUrl); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new BlacklistValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame($rawUrl, $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php new file mode 100644 index 00000000..e9278a42 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php @@ -0,0 +1,104 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(string $uid = 'UID-1'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new ConfirmationUrlValueResolver($this->config); + $this->assertSame('CONFIRMATIONURL', $resolver->name()); + } + + public function testHtmlWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-42'), + format: OutputFormat::Html, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $result = $resolver($ctx); + + $this->assertSame('https://example.com/confirm.php?uid=U-42', $result); + } + + public function testHtmlWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UIDX'), + format: OutputFormat::Html, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $result = $resolver($ctx); + + $this->assertSame('https://example.com/confirm.php?a=1&uid=UIDX', $result); + // Ensure it decodes to the right raw URL + $this->assertSame('https://example.com/confirm.php?a=1&uid=UIDX', html_entity_decode($result)); + } + + public function testTextWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-7'), + format: OutputFormat::Text, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $this->assertSame('https://example.com/confirm.php?uid=U-7', $resolver($ctx)); + } + + public function testTextWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php?x=9'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UU-1'), + format: OutputFormat::Text, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $this->assertSame('https://example.com/confirm.php?x=9&uid=UU-1', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php new file mode 100644 index 00000000..07801cec --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php @@ -0,0 +1,103 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-1'); + return $u; + } + + public function testName(): void + { + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame('CONTACTURL', $resolver->name()); + } + + public function testHtmlEscapesUrl(): void + { + $raw = 'https://example.com/vcard.php?a=1&b=2&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $result = $resolver($ctx); + + // Match implementation defaults of htmlspecialchars + $this->assertSame(htmlspecialchars($raw), $result); + } + + public function testTextReturnsPlainUrl(): void + { + $raw = 'https://example.com/vcard.php?a=1&b=2'; + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame($raw, $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNullForHtml(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn(null); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame('', $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNullForText(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn(null); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame('', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php new file mode 100644 index 00000000..e11a1365 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php @@ -0,0 +1,116 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-C'); + return $u; + } + + public function testPatternMatchesBothContactForms(): void + { + $resolver = new ContactValueResolver($this->config, $this->translator); + + $pattern = $resolver->pattern(); + $this->assertSame(1, preg_match($pattern, '[CONTACT]')); + $this->assertSame(1, preg_match($pattern, '[Contact:123]')); + } + + public function testHtmlReturnsAnchorWithEscapedUrlAndLabel(): void + { + $rawUrl = 'https://example.com/vcard.php?a=1&b=2&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn($rawUrl); + + $this->translator->method('trans') + ->with('Add us to your address book') + ->willReturn('Add & keep in "book" <>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new ContactValueResolver($this->config, $this->translator); + + // simulate regex matches (index 1 is optional number, can be missing) + $matches = ['[CONTACT]', null]; + + $result = $resolver($ctx, $matches); + + $expectedHref = htmlspecialchars($rawUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedText = htmlspecialchars('Add & keep in "book" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expected = sprintf('%s', $expectedHref, $expectedText); + + $this->assertSame($expected, $result); + } + + public function testTextReturnsLabelColonUrlWhenLabelNonEmpty(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn('https://example.com/vcard.php'); + + $this->translator->method('trans') + ->with('Add us to your address book') + ->willReturn('Add us to your address book'); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactValueResolver($this->config, $this->translator); + $out = $resolver($ctx, ['[CONTACT]']); + + $this->assertSame('Add us to your address book: https://example.com/vcard.php', $out); + } + + public function testTextReturnsJustUrlWhenLabelEmpty(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn('https://example.com/vcard.php?x=1'); + + $this->translator->method('trans') + ->with('Add us to your address book') + ->willReturn(''); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactValueResolver($this->config, $this->translator); + $out = $resolver($ctx, ['[CONTACT:9]', '9']); + + $this->assertSame('https://example.com/vcard.php?x=1', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php new file mode 100644 index 00000000..73a464c1 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php @@ -0,0 +1,80 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-FTR'); + return $u; + } + + public function testName(): void + { + $resolver = new FooterValueResolver($this->config, false); + $this->assertSame('FOOTER', $resolver->name()); + } + + public function testReturnsConfigForwardFooterByDefault(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardFooter) + ->willReturn('Default footer'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + + $resolver = new FooterValueResolver($this->config, false); + $this->assertSame('Default footer', $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardFooter) + ->willReturn(null); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new FooterValueResolver($this->config, false); + $this->assertSame('', $resolver($ctx)); + } + + public function testReturnsDtoFooterWhenForwardAlternativeContentEnabledAndDtoPresent(): void + { + $dto = new MessagePrecacheDto(); + // with backslashes + $dto->footer = 'A\\B\\C'; + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: $dto, + ); + + // When alternative content flag is on, config should be ignored and dto footer used (with stripslashes) + $resolver = new FooterValueResolver($this->config, true); + $this->assertSame('ABC', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php new file mode 100644 index 00000000..7fda2280 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php @@ -0,0 +1,146 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'U-FWD'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testPatternMatchesBothForms(): void + { + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $pattern = $resolver->pattern(); + + $this->assertSame(1, preg_match($pattern, '[FORWARD:123]')); + $this->assertSame(1, preg_match($pattern, '[FORWARD:123:Share]')); + } + + public function testHtmlWithDefaultTranslatedLabelAndNoQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('Click & go'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-99'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + ); + + $matches = ['[FORWARD:77]', '77']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $this->assertSame( + '' + . htmlspecialchars('Click & go', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '', + $out + ); + } + + public function testHtmlWithCustomLabelAndExistingQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-A'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + ); + + $matches = ['[FORWARD:15:Share & enjoy]', '15:Share & enjoy']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $expectedHref = 'https://example.com/forward.php?a=1&uid=U-A&mid=15'; + $expectedLabel = htmlspecialchars('Share & enjoy', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . '', $out); + } + + public function testTextWithDefaultTranslatedLabel(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('Open'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-TX'), format: OutputFormat::Text); + $matches = ['[FORWARD:3]', '3']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $this->assertSame('Open https://example.com/forward.php?uid=U-TX&mid=3', $out); + } + + public function testTextWithCustomLabelAndExistingQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?x=9'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-XY'), format: OutputFormat::Text); + $matches = ['[FORWARD:44:Share it]', '44:Share it']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $this->assertSame('Share it https://example.com/forward.php?x=9&uid=U-XY&mid=44', $out); + } + + public function testEmptyOrWhitespaceIdReturnsEmptyString(): void + { + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $matches = ['[FORWARD: ]', ' ']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $this->assertSame('', $resolver($ctx, $matches)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php new file mode 100644 index 00000000..dac64444 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php @@ -0,0 +1,124 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(string $uid = 'U1'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new ForwardUrlValueResolver($this->config); + $this->assertSame('FORWARDURL', $resolver->name()); + } + + public function testHtmlWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UID-42'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 5, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?uid=UID-42&mid=5', $out); + } + + public function testHtmlWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-7'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 15, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?a=1&uid=U-7&mid=15', $out); + // Raw decode should match with & between params + $this->assertSame('https://example.com/forward.php?a=1&uid=U-7&mid=15', html_entity_decode($out)); + } + + public function testTextWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-T'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 2, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?uid=U-T&mid=2', $out); + } + + public function testTextWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?x=9'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-Z'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 88, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?x=9&uid=U-Z&mid=88', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php new file mode 100644 index 00000000..944cf1ef --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php @@ -0,0 +1,145 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'UID-F'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new ForwardValueResolver($this->config, $this->translator); + $this->assertSame('FORWARD', $resolver->name()); + } + + public function testHtmlReturnsLinkWithEscapedHrefAndLabelNoQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('Click & share "now" <>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-1'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 77, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = 'https://example.com/forward.php?uid=U-1&mid=77'; + $expectedLabel = htmlspecialchars('Click & share "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . ' ', $out); + } + + public function testHtmlReturnsLinkWithEscapedHrefAndLabelWithExistingQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('This <&>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-2'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 5, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = 'https://example.com/forward.php?a=1&uid=U-2&mid=5'; + $expectedLabel = htmlspecialchars('This <&>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . ' ', $out); + $this->assertSame( + 'https://example.com/forward.php?a=1&uid=U-2&mid=5', + html_entity_decode($expectedHref) + ); + } + + public function testTextReturnsRawUrlWithTrailingSpace(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-3'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 9, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?uid=U-3&mid=9 ', $out); + } + + public function testTextWithExistingQueryHasAmpersandAndTrailingSpace(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-4'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 11, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?a=1&uid=U-4&mid=11 ', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php new file mode 100644 index 00000000..842e6f8e --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php @@ -0,0 +1,73 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $uid = 'UID-JOU'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new JumpoffUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('JUMPOFFURL', $resolver->name()); + } + + public function testHtmlReturnsEmptyString(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsub.php'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsub.php', 'UH-1') + ->willReturn('https://example.com/unsub.php?uid=UH-1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UH-1'), format: OutputFormat::Html); + $resolver = new JumpoffUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('', $resolver($ctx)); + } + + public function testTextReturnsPlainUrlWithJoParam(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsub.php?a=1'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsub.php?a=1', 'U-T1') + ->willReturn('https://example.com/unsub.php?a=1&uid=U-T1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-T1'), format: OutputFormat::Text); + $resolver = new JumpoffUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('https://example.com/unsub.php?a=1&uid=U-T1&jo=1', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php new file mode 100644 index 00000000..521bd06b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php @@ -0,0 +1,93 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $uid = 'UID-JO'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + $this->assertSame('JUMPOFF', $resolver->name()); + } + + public function testHtmlReturnsEmptyStringButBuildsUrlWithUid(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsubscribe.php'); + + // Even though HTML returns empty string, implementation builds URL first + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsubscribe.php', 'UID-H') + ->willReturn('https://example.com/unsubscribe.php?uid=UID-H'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-H'), format: OutputFormat::Html); + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('', $resolver($ctx)); + } + + public function testTextReturnsPlainUrlWithUidAndJoParamWhenNoExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsubscribe.php'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsubscribe.php', 'U-1') + ->willReturn('https://example.com/unsubscribe.php?uid=U-1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-1'), format: OutputFormat::Text); + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('https://example.com/unsubscribe.php?uid=U-1&jo=1', $resolver($ctx)); + } + + public function testTextReturnsPlainUrlWithUidAndJoParamWhenExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsubscribe.php?a=1'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsubscribe.php?a=1', 'U-2') + ->willReturn('https://example.com/unsubscribe.php?a=1&uid=U-2'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-2'), format: OutputFormat::Text); + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('https://example.com/unsubscribe.php?a=1&uid=U-2&jo=1', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php new file mode 100644 index 00000000..df0f2224 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php @@ -0,0 +1,114 @@ +repo = $this->createMock(SubscriberListRepository::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-L'); + return $u; + } + + public function testName(): void + { + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + $this->assertSame('LISTS', $resolver->name()); + } + + public function testReturnsTranslatedMessageWhenNoLists(): void + { + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), false) + ->willReturn([]); + + $this->translator->method('trans') + ->with('Sorry, you are not subscribed to any of our newsletters with this email address.') + ->willReturn('No subscriptions'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + + $this->assertSame('No subscriptions', $resolver($ctx)); + } + + public function testHtmlEscapesNamesAndJoinsWithBr(): void + { + $names = ['News & Updates', 'Special ', "Quotes ' \" "]; + + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), false) + ->willReturn($names); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + + $out = $resolver($ctx); + + $expected = implode( + '
', + array_map( + static fn(string $n) => htmlspecialchars($n, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + $names + ) + ); + + $this->assertSame($expected, $out); + } + + public function testTextJoinsWithNewlinesWithoutEscaping(): void + { + $names = ['General', 'Dev & QA', 'Sales ']; + + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), false) + ->willReturn($names); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + + $out = $resolver($ctx); + + $this->assertSame(implode("\n", $names), $out); + } + + public function testRespectsShowPrivateFlagTrue(): void + { + $names = ['Private List']; + + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), true) + ->willReturn($names); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new ListsValueResolver($this->repo, $this->translator, true); + + $this->assertSame('Private List', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php new file mode 100644 index 00000000..24fc705d --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php @@ -0,0 +1,82 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-PREF'); + return $u; + } + + public function testName(): void + { + $resolver = new PreferencesUrlValueResolver($this->config); + $this->assertSame('PREFERENCESURL', $resolver->name()); + } + + public function testTextUrlWithUidAppended(): void + { + $raw = 'https://example.com/prefs.php'; + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + + $resolver = new PreferencesUrlValueResolver($this->config); + $this->assertSame($raw . '?uid=UID-PREF', $resolver($ctx)); + } + + public function testTextUrlUsesAmpersandWhenQueryPresent(): void + { + $raw = 'https://example.com/prefs.php?a=1'; + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + + $resolver = new PreferencesUrlValueResolver($this->config); + $this->assertSame($raw . '&uid=UID-PREF', $resolver($ctx)); + } + + public function testHtmlEscapesUrlAndAppendsUid(): void + { + $raw = 'https://e.com/prefs.php?a=1&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new PreferencesUrlValueResolver($this->config); + $result = $resolver($ctx); + + $this->assertSame( + sprintf('%s%suid=%s', htmlspecialchars($raw), htmlspecialchars('&'), 'UID-PREF'), + $result + ); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php new file mode 100644 index 00000000..f59649d8 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php @@ -0,0 +1,110 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'UID-PREV'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new PreferencesValueResolver($this->config, $this->translator); + $this->assertSame('PREFERENCES', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithEscapedHrefAndLabelNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php'); + $this->translator->method('trans') + ->with('This link') + ->willReturn('Click & manage "prefs" <>'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-1'), format: OutputFormat::Html); + + $resolver = new PreferencesValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars('https://example.com/prefs.php', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . htmlspecialchars('?') + . 'uid=U-1'; + $expectedLabel = htmlspecialchars('Click & manage "prefs" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $this->assertSame('' . $expectedLabel . ' ', $out); + } + + public function testHtmlReturnsAnchorWithAmpersandWhenQueryPresent(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php?a=1'); + $this->translator->method('trans') + ->with('This link') + ->willReturn('Go to prefs'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-2'), format: OutputFormat::Html); + + $resolver = new PreferencesValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars('https://example.com/prefs.php?a=1', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . htmlspecialchars('&') + . 'uid=U-2'; + $expectedLabel = htmlspecialchars('Go to prefs', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $this->assertSame('' . $expectedLabel . ' ', $out); + $this->assertSame('https://example.com/prefs.php?a=1&uid=U-2', $expectedHref); + } + + public function testTextReturnsUrlWithUidNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-3'), format: OutputFormat::Text); + $resolver = new PreferencesValueResolver($this->config, $this->translator); + + $this->assertSame('https://example.com/prefs.php?uid=U-3', $resolver($ctx)); + } + + public function testTextReturnsUrlWithUidWhenQueryPresent(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php?a=1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-4'), format: OutputFormat::Text); + $resolver = new PreferencesValueResolver($this->config, $this->translator); + + $this->assertSame('https://example.com/prefs.php?a=1&uid=U-4', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php new file mode 100644 index 00000000..03928966 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php @@ -0,0 +1,96 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-SIG'); + return $u; + } + + public function testName(): void + { + $resolver = new SignatureValueResolver($this->config); + $this->assertSame('SIGNATURE', $resolver->name()); + } + + public function testHtmlReturnsPoweredByTextWhenTextCreditsTrue(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PoweredByText) + ->willReturn('Powered by phpList'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, true); + + $this->assertSame('Powered by phpList', $resolver($ctx)); + } + + public function testHtmlReturnsEmptyWhenPoweredByTextNullAndTextCreditsTrue(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PoweredByText) + ->willReturn(null); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, true); + + $this->assertSame('', $resolver($ctx)); + } + + public function testHtmlReplacesImageSrcWhenTextCreditsFalse(): void + { + $html = ''; + $this->config->method('getValue') + ->with(ConfigOption::PoweredByImage) + ->willReturn($html); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, false); + + $out = $resolver($ctx); + $this->assertStringContainsString('src="powerphplist.png"', $out); + } + + public function testHtmlReturnsEmptyWhenPoweredByImageNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PoweredByImage) + ->willReturn(null); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, false); + + $this->assertSame('', $resolver($ctx)); + } + + public function testTextReturnsFixedSignature(): void + { + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new SignatureValueResolver($this->config, false); + + $this->assertSame("\n\n-- powered by phpList, www.phplist.com --\n\n", $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php new file mode 100644 index 00000000..530a1dc5 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php @@ -0,0 +1,74 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-SUB'); + return $u; + } + + public function testName(): void + { + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame('SUBSCRIBEURL', $resolver->name()); + } + + public function testHtmlEscapesUrl(): void + { + $raw = 'https://example.com/sub.php?a=1&b=2&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame(htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), $resolver($ctx)); + } + + public function testTextReturnsPlainUrl(): void + { + $raw = 'https://example.com/sub.php?a=1&b=2'; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame($raw, $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn(null); + + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php new file mode 100644 index 00000000..b37774e1 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php @@ -0,0 +1,86 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-SV'); + return $u; + } + + public function testName(): void + { + $resolver = new SubscribeValueResolver($this->config, $this->translator); + $this->assertSame('SUBSCRIBE', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithEscapedHrefAndLabel(): void + { + $rawUrl = 'https://example.com/sub.php?a=1&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($rawUrl); + + $this->translator->method('trans') + ->with('This link') + ->willReturn('Click & join "now" <>'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new SubscribeValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars($rawUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedLabel = htmlspecialchars('Click & join "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . '', $out); + } + + public function testTextReturnsPlainUrl(): void + { + $raw = 'https://example.com/sub.php?a=1&b=2'; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new SubscribeValueResolver($this->config, $this->translator); + $this->assertSame($raw, $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn(null); + + $resolver = new SubscribeValueResolver($this->config, $this->translator); + + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php new file mode 100644 index 00000000..4ca95e8c --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php @@ -0,0 +1,95 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $uid = 'UID-UNSUB'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('UNSUBSCRIBEURL', $resolver->name()); + } + + public function testHtmlEscapesBuiltUrl(): void + { + $base = 'https://example.com/unsub.php?a=1&x='; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $built = $base . '&uid=UID-UNSUB'; + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'UID-UNSUB') + ->willReturn($built); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-UNSUB'), format: OutputFormat::Html); + + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + $result = $resolver($ctx); + + $this->assertSame(htmlspecialchars($built, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), $result); + } + + public function testTextReturnsBuiltUrl(): void + { + $base = 'https://example.com/unsub.php?a=1'; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'U-TXT') + ->willReturn('https://example.com/unsub.php?a=1&uid=U-TXT'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-TXT'), format: OutputFormat::Text); + + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('https://example.com/unsub.php?a=1&uid=U-TXT', $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNullOrEmpty(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn(null); + + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php new file mode 100644 index 00000000..8c33334a --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php @@ -0,0 +1,147 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'UID-U'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('UNSUBSCRIBE', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithEscapedHrefAndLabel(): void + { + $base = 'https://example.com/unsub.php?a=1&x='; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $built = $base . '&uid=UID-H'; + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'UID-H') + ->willReturn($built); + + $this->translator + ->method('trans') + ->with('Unsubscribe') + ->willReturn('Unsubscribe & confirm "now" <>'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-H'), format: OutputFormat::Html); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars($built, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedLabel = htmlspecialchars('Unsubscribe & confirm "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $this->assertSame('' . $expectedLabel . '', $out); + } + + public function testTextReturnsBuiltUrl(): void + { + $base = 'https://example.com/unsub.php?a=1'; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'UID-TXT') + ->willReturn('https://example.com/unsub.php?a=1&uid=UID-TXT'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-TXT'), format: OutputFormat::Text); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('https://example.com/unsub.php?a=1&uid=UID-TXT', $resolver($ctx)); + } + + public function testForwardedByUsesBlacklistUrl(): void + { + $unsubscribeBase = 'https://example.com/unsub.php'; + $blacklistBase = 'https://example.com/black.php'; + + $this->config->method('getValue') + ->willReturnMap( + [ + [ConfigOption::UnsubscribeUrl, $unsubscribeBase], + [ConfigOption::BlacklistUrl, $blacklistBase], + ] + ); + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($blacklistBase, 'UID-FWD') + ->willReturn($blacklistBase . '?uid=UID-FWD'); + + $this->translator + ->method('trans') + ->with('Unsubscribe') + ->willReturn('Unsubscribe'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UID-FWD'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: 'someone@example.com', + ); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $out = $resolver($ctx); + + $this->assertStringContainsString( + 'href="' + . htmlspecialchars($blacklistBase . '?uid=UID-FWD', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '"', + $out + ); + } + + public function testReturnsEmptyStringWhenBaseMissing(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn(null); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php new file mode 100644 index 00000000..8a757cd4 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php @@ -0,0 +1,103 @@ +repo = $this->createMock(SubscriberRepository::class); + } + + private function makeCtx(Subscriber $user = null): PlaceholderContext + { + $u = $user ?? (function () { + $s = new Subscriber(); + $s->setEmail('user@example.com'); + $s->setUniqueId('UID-X'); + // Ensure the entity has a non-null ID for repository lookup + $rp = new \ReflectionProperty(Subscriber::class, 'id'); + $rp->setAccessible(true); + $rp->setValue($s, 42); + return $s; + })(); + + return new PlaceholderContext($u, OutputFormat::Text); + } + + public function testSupportsIsCaseInsensitiveForKnownKeys(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + + $ctx = $this->makeCtx(); + $this->assertTrue($resolver->supports('confirmed', $ctx)); + $this->assertTrue($resolver->supports('CONFIRMED', $ctx)); + $this->assertTrue($resolver->supports('UniqId', $ctx)); + $this->assertFalse($resolver->supports('UNKNOWN_KEY', $ctx)); + } + + public function testResolveReturnsScalarStringForMatchingKey(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + $ctx = $this->makeCtx(); + + $this->repo->expects($this->once()) + ->method('getDataById') + ->with($ctx->getUser()->getId()) + ->willReturn( + [ + 'confirmed' => true, + 'uniqid' => 'ABC123', + ] + ); + + $this->assertSame('ABC123', $resolver->resolve('uniqid', $ctx)); + } + + public function testResolveReturnsNullWhenValueNullOrEmpty(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + $ctx = $this->makeCtx(); + + $this->repo->method('getDataById') + ->with($ctx->getUser()->getId()) + ->willReturn( + [ + 'uuid' => null, + 'foreignkey' => '', + ] + ); + + $this->assertNull($resolver->resolve('uuid', $ctx)); + $this->assertNull($resolver->resolve('foreignkey', $ctx)); + } + + public function testResolveReturnsNullWhenKeyAbsent(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + $ctx = $this->makeCtx(); + + $this->repo->method('getDataById') + ->with($ctx->getUser()->getId()) + ->willReturn( + [ + 'confirmed' => 1, + 'uniqid' => 'Z', + ] + ); + + $this->assertNull($resolver->resolve('rssfrequency', $ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php new file mode 100644 index 00000000..f68ce777 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php @@ -0,0 +1,92 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(string $uid = 'U-42'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + $this->assertSame('USERTRACK', $resolver->name()); + } + + public function testReturnsEmptyForTextFormat(): void + { + $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $this->assertSame('', $resolver($ctx)); + } + + public function testHtmlUsesConfigDomainWhenAvailable(): void + { + $this->config->method('getValue') + ->with(ConfigOption::Domain) + ->willReturn('example.com'); + + $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UID-XYZ'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 99, + ); + + $result = $resolver($ctx); + + $expected = ''; + // Normalize double quotes for comparison + $this->assertSame($expected, $result); + } + + public function testHtmlFallsBackToRestApiDomainWhenConfigMissing(): void + { + $this->config->method('getValue') + ->with(ConfigOption::Domain) + ->willReturn(null); + + $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U1'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 7, + ); + + $result = $resolver($ctx); + + $expected = ''; + $this->assertSame($expected, $result); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php index e2a1d719..36bb5626 100644 --- a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service; +use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; use PhpList\Core\Domain\Configuration\Service\PlaceholderResolver; use PHPUnit\Framework\TestCase; @@ -12,31 +13,33 @@ */ final class PlaceholderResolverTest extends TestCase { - public function testNullAndEmptyAreReturnedAsIs(): void + public function testEmptyAreReturnedAsIs(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); - $this->assertNull($resolver->resolve(null)); - $this->assertSame('', $resolver->resolve('')); + $this->assertSame('', $resolver->resolve('', $placeholderContext)); } public function testUnregisteredTokensRemainUnchanged(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hello [NAME], click [UNSUBSCRIBEURL] to opt out.'; - $this->assertSame($input, $resolver->resolve($input)); + $this->assertSame($input, $resolver->resolve($input, $placeholderContext)); } public function testCaseInsensitiveTokenResolution(): void { $resolver = new PlaceholderResolver(); $resolver->register('unsubscribeurl', fn () => 'https://u.example/u/123'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Click [UnSubscribeUrl]'; $expect = 'Click https://u.example/u/123'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testMultipleDifferentTokensAreResolved(): void @@ -44,16 +47,18 @@ public function testMultipleDifferentTokensAreResolved(): void $resolver = new PlaceholderResolver(); $resolver->register('NAME', fn () => 'Ada'); $resolver->register('EMAIL', fn () => 'ada@example.com'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hi [NAME] <[email]>'; $expect = 'Hi Ada '; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testAdjacentAndRepeatedTokens(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); $count = 0; $resolver->register('X', function () use (&$count) { @@ -64,7 +69,7 @@ public function testAdjacentAndRepeatedTokens(): void $input = 'Start [x][X]-[x] End'; $expect = 'Start VV-V End'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); $this->assertSame(3, $count); } @@ -72,21 +77,23 @@ public function testDigitsAndUnderscoresInToken(): void { $resolver = new PlaceholderResolver(); $resolver->register('USER_2', fn () => 'Bob#2'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hello [user_2]!'; $expect = 'Hello Bob#2!'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testUnknownTokensArePreservedVerbatim(): void { $resolver = new PlaceholderResolver(); $resolver->register('KNOWN', fn () => 'K'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'A[UNKNOWN]B[KNOWN]C'; $expect = 'A[UNKNOWN]BKC'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } } diff --git a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php index 0c7f7dfd..5a83a739 100644 --- a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php +++ b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php @@ -5,8 +5,13 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service; use PhpList\Core\Domain\Configuration\Model\ConfigOption; -use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; +use PhpList\Core\Domain\Configuration\Service\Placeholder\ConfirmationUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver; use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; @@ -20,7 +25,6 @@ final class UserPersonalizerTest extends TestCase { private ConfigProvider&MockObject $config; - private LegacyUrlBuilder&MockObject $urlBuilder; private SubscriberRepository&MockObject $subRepo; private SubscriberAttributeValueRepository&MockObject $attrRepo; private AttributeValueResolver&MockObject $attrResolver; @@ -29,17 +33,22 @@ final class UserPersonalizerTest extends TestCase protected function setUp(): void { $this->config = $this->createMock(ConfigProvider::class); - $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); $this->subRepo = $this->createMock(SubscriberRepository::class); $this->attrRepo = $this->createMock(SubscriberAttributeValueRepository::class); $this->attrResolver = $this->createMock(AttributeValueResolver::class); $this->personalizer = new UserPersonalizer( - $this->config, - $this->urlBuilder, - $this->subRepo, - $this->attrRepo, - $this->attrResolver + config: $this->config, + subscriberRepository: $this->subRepo, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + unsubscribeUrlValueResolver: new UnsubscribeUrlValueResolver( + config: $this->config, + urlBuilder: new LegacyUrlBuilder() + ), + confirmationUrlValueResolver: new ConfirmationUrlValueResolver($this->config), + preferencesUrlValueResolver: new PreferencesUrlValueResolver($this->config), + subscribeUrlValueResolver: new SubscribeUrlValueResolver($this->config), ); } @@ -51,7 +60,7 @@ public function testReturnsOriginalWhenSubscriberNotFound(): void ->with('nobody@example.com') ->willReturn(null); - $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com'); + $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com', OutputFormat::Text); $this->assertSame('Hello [EMAIL]', $result); } @@ -84,11 +93,6 @@ public function testBuiltInPlaceholdersAreResolved(): void }; }); - // LegacyUrlBuilder glue behavior - $this->urlBuilder - ->method('withUid') - ->willReturnCallback(fn(string $base, string $u) => $base . '?uid=' . $u); - $this->attrRepo ->expects($this->once()) ->method('getForSubscriber') @@ -97,21 +101,20 @@ public function testBuiltInPlaceholdersAreResolved(): void $input = 'Email: [EMAIL] Unsub: [UNSUBSCRIBEURL] - Conf: [confirmationurl] + Conf: [CONFIRMATIONURL] Prefs: [PREFERENCESURL] Sub: [SUBSCRIBEURL] Domain: [DOMAIN] Website: [WEBSITE]'; - $result = $this->personalizer->personalize($input, $email); + $result = $this->personalizer->personalize($input, $email, OutputFormat::Text); $this->assertStringContainsString('Email: ada@example.com', $result); - // trailing space is expected after URL placeholders - $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123 ', $result); - $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123 ', $result); - $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123 ', $result); - $this->assertStringContainsString('Sub: https://u.example/subscribe ', $result); + $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123', $result); + $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123', $result); + $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123', $result); + $this->assertStringContainsString('Sub: https://u.example/subscribe', $result); $this->assertStringContainsString('Domain: example.org', $result); $this->assertStringContainsString('Website: site.example.org', $result); } @@ -141,8 +144,6 @@ public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void [ConfigOption::Website, 'site.example.org'], ]); - $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); - // Build a fake attribute value entity with definition NAME => "Full Name" $attrDefinition = $this->createMock(SubscriberAttributeDefinition::class); $attrDefinition->method('getName')->willReturn('Full_Name2'); @@ -163,7 +164,7 @@ public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void ->willReturn('Bob #2'); $input = 'Hello [full_name2], your email is [email].'; - $result = $this->personalizer->personalize($input, $email); + $result = $this->personalizer->personalize($input, $email, OutputFormat::Text); $this->assertSame('Hello Bob #2, your email is bob@example.com.', $result); } @@ -188,8 +189,6 @@ public function testMultipleOccurrencesAndAdjacency(): void [ConfigOption::Website, 'w.x.tld'], ]); - $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); - // Two attributes: FOO & BAR $defFoo = $this->createMock(SubscriberAttributeDefinition::class); $defFoo->method('getName')->willReturn('FOO'); @@ -211,8 +210,8 @@ public function testMultipleOccurrencesAndAdjacency(): void ]); $input = '[foo][BAR]-[email]-[UNSUBSCRIBEURL]'; - $out = $this->personalizer->personalize($input, $email); + $out = $this->personalizer->personalize($input, $email, OutputFormat::Text); - $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42 ', $out); + $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42', $out); } } diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php index edf16d37..1a7a885e 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -4,11 +4,11 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; -use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; -use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeDefinitionManager; use PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php index d0ea805c..697798b7 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php @@ -4,12 +4,12 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\AdminAttributeValue; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository; -use PhpList\Core\Domain\Identity\Service\AdminAttributeManager; -use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php index 8a61b4fd..0a56460f 100644 --- a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; use PhpList\Core\Domain\Identity\Model\Dto\UpdateAdministratorDto; -use PhpList\Core\Domain\Identity\Service\AdministratorManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; use PhpList\Core\Security\HashGenerator; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index b9b53039..fd7aae53 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -5,18 +5,18 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; use DateTime; -use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; -use PhpList\Core\Domain\Identity\Service\PasswordManager; +use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Service\Manager\PasswordManager; use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; use PhpList\Core\Security\HashGenerator; -use Symfony\Component\Messenger\Envelope; -use Symfony\Component\Messenger\MessageBusInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManagerTest extends TestCase diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index da620f12..e655f4a5 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; -use PhpList\Core\Domain\Identity\Service\SessionManager; +use PhpList\Core\Domain\Identity\Service\Manager\SessionManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php b/tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php new file mode 100644 index 00000000..78358a92 --- /dev/null +++ b/tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php @@ -0,0 +1,89 @@ +from('from@example.com') + ->to('to@example.com') + ->subject('Subject') + ->text('Body'); + + $event = new MessageEvent($email, Envelope::create($email), 'test'); + + $subscriber->onMessage($event); + + $this->assertFalse( + $email->getHeaders()->has('X-phpList-Injected-By'), + 'Header must not be added when there is no current Request.' + ); + } + + public function testNoHeaderWhenMessageIsNotEmail(): void + { + $requestStack = new RequestStack(); + // Push a Request to ensure the early return is due to non-Email message, not missing request + $requestStack->push(new Request(server: ['REQUEST_TIME' => time()])); + + $subscriber = new InjectedByHeaderSubscriber($requestStack); + + $raw = new RawMessage('raw'); + // Create an arbitrary envelope; it does not need to match the message class + $envelope = new Envelope(new Address('from@example.com'), [new Address('to@example.com')]); + $event = new MessageEvent($raw, $envelope, 'test'); + + // RawMessage has no headers; the subscriber should return early + $subscriber->onMessage($event); + // sanity check to use the variable + $this->assertSame('raw', $raw->toString()); + // Nothing to assert on headers (RawMessage has none), but the lack of exceptions is a success + $this->addToAssertionCount(1); + } + + public function testNoHeaderWhenRunningInCliEvenWithRequestAndEmail(): void + { + // In PHPUnit, PHP_SAPI is typically "cli"; ensure we have a Request to pass other guards + $request = new Request(server: [ + 'REQUEST_TIME' => time(), + 'REMOTE_ADDR' => '127.0.0.1', + ]); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $subscriber = new InjectedByHeaderSubscriber($requestStack); + + $email = (new Email()) + ->from('from@example.com') + ->to('to@example.com') + ->subject('Subject') + ->text('Body'); + + $event = new MessageEvent($email, Envelope::create($email), 'test'); + + $subscriber->onMessage($event); + + // Because tests run under CLI SAPI, the header must not be added + $this->assertFalse( + $email->getHeaders()->has('X-phpList-Injected-By'), + 'Header must not be added when running under CLI.' + ); + } +} diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index a565f558..dd562504 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -6,18 +6,23 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; +use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; +use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager; +use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; +use PhpList\Core\Domain\Messaging\Service\MessageDataLoader; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Messaging\Service\MessagePrecacheService; use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; @@ -28,6 +33,8 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use ReflectionClass; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Translation\Translator; use Symfony\Contracts\Translation\TranslatorInterface; @@ -43,6 +50,8 @@ class CampaignProcessorMessageHandlerTest extends TestCase private MessageRepository|MockObject $messageRepository; private TranslatorInterface|MockObject $translator; private MessagePrecacheService|MockObject $precacheService; + private CacheInterface|MockObject $cache; + private MailerInterface|MockObject $symfonyMailer; protected function setUp(): void { @@ -57,27 +66,33 @@ protected function setUp(): void $requeueHandler = $this->createMock(RequeueHandler::class); $this->translator = $this->createMock(Translator::class); $this->precacheService = $this->createMock(MessagePrecacheService::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->symfonyMailer = $this->createMock(MailerInterface::class); $timeLimiter->method('start'); $timeLimiter->method('shouldStop')->willReturn(false); $this->handler = new CampaignProcessorMessageHandler( - mailer: $this->mailer, + mailer: $this->symfonyMailer, + rateLimitedCampaignMailer: $this->mailer, entityManager: $this->entityManager, subscriberProvider: $this->subscriberProvider, messagePreparator: $this->messagePreparator, logger: $this->logger, - cache: $this->createMock(CacheInterface::class), + cache: $this->cache, userMessageRepository: $userMessageRepository, timeLimiter: $timeLimiter, requeueHandler: $requeueHandler, translator: $this->translator, subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), messageRepository: $this->messageRepository, - eventLogManager: $this->createMock(EventLogManager::class), - messageDataManager: $this->createMock(MessageDataManager::class), precacheService: $this->precacheService, - maxMailSize: 0, + messageDataLoader: $this->createMock(MessageDataLoader::class), + systemEmailBuilder: $this->createMock(SystemEmailBuilder::class), + campaignEmailBuilder: $this->createMock(EmailBuilder::class), + mailSizeChecker: $this->createMock(MailSizeChecker::class), + configProvider: $this->createMock(ConfigProvider::class), + bounceEmail: 'bounce@email.com', ); } @@ -110,6 +125,11 @@ public function testInvokeWithNoSubscribers(): void ->with(1, MessageStatus::Submitted) ->willReturn($campaign); + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + $this->subscriberProvider->expects($this->once()) ->method('getSubscribersForMessage') ->with($campaign) @@ -121,7 +141,7 @@ public function testInvokeWithNoSubscribers(): void $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); - $this->mailer->expects($this->never()) + $this->symfonyMailer->expects($this->never()) ->method('send'); ($this->handler)(new CampaignProcessorMessage(1)); @@ -138,6 +158,11 @@ public function testInvokeWithInvalidSubscriberEmail(): void ->with(1, MessageStatus::Submitted) ->willReturn($campaign); + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getEmail')->willReturn('invalid-email'); $subscriber->method('getId')->willReturn(1); @@ -156,7 +181,7 @@ public function testInvokeWithInvalidSubscriberEmail(): void $this->messagePreparator->expects($this->never()) ->method('processMessageLinks'); - $this->mailer->expects($this->never()) + $this->symfonyMailer->expects($this->never()) ->method('send'); ($this->handler)(new CampaignProcessorMessage(1)); @@ -165,8 +190,12 @@ public function testInvokeWithInvalidSubscriberEmail(): void public function testInvokeWithValidSubscriberEmail(): void { $campaign = $this->createMock(Message::class); - $content = $this->createContentMock(); - $campaign->method('getContent')->willReturn($content); + $precached = new MessagePrecacheDto(); + $precached->subject = 'Test Subject'; + $precached->content = '

Test HTML message

'; + $precached->textContent = 'Test text message'; + $precached->footer = 'Test footer message'; + $campaign->method('getContent')->willReturn($this->createContentMock()); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); $campaign->method('getId')->willReturn(1); @@ -175,6 +204,13 @@ public function testInvokeWithValidSubscriberEmail(): void ->with(1, MessageStatus::Submitted) ->willReturn($campaign); + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $this->cache->method('get')->willReturn($precached); + $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getEmail')->willReturn('test@example.com'); $subscriber->method('getId')->willReturn(1); @@ -186,31 +222,28 @@ public function testInvokeWithValidSubscriberEmail(): void $this->messagePreparator->expects($this->once()) ->method('processMessageLinks') - ->willReturn($content); - - $this->mailer->expects($this->once()) - ->method('composeEmail') - ->with( - $this->identicalTo($campaign), - $this->identicalTo($subscriber), - $this->identicalTo($content) - ) - ->willReturnCallback(function ($camp, $sub, $proc) use ($campaign, $subscriber, $content) { - $this->assertSame($campaign, $camp); - $this->assertSame($subscriber, $sub); - $this->assertSame($content, $proc); - - return (new Email()) + ->with(1, $precached, $subscriber) + ->willReturn($precached); + + // campaign emails are built via campaignEmailBuilder and sent via RateLimitedCampaignMailer + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty('campaignEmailBuilder'); + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); + + $campaignBuilderMock->expects($this->once()) + ->method('buildPhplistEmail') + ->willReturn([ + (new Email()) ->from('news@example.com') ->to('test@example.com') ->subject('Test Subject') ->text('Test text message') - ->html('

Test HTML message

'); - }); + ->html('

Test HTML message

'), + OutputFormat::Html + ]); - $this->mailer->expects($this->once()) - ->method('send') - ->with($this->isInstanceOf(Email::class)); + $this->mailer->expects($this->any())->method('send'); $metadata->expects($this->atLeastOnce()) ->method('setStatus'); @@ -224,9 +257,13 @@ public function testInvokeWithValidSubscriberEmail(): void public function testInvokeWithMailerException(): void { $campaign = $this->createMock(Message::class); - $content = $this->createContentMock(); + $precached = new MessagePrecacheDto(); + $precached->subject = 'Test Subject'; + $precached->content = '

Test HTML message

'; + $precached->textContent = 'Test text message'; + $precached->footer = 'Test footer message'; $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getContent')->willReturn($content); + $campaign->method('getContent')->willReturn($this->createContentMock()); $campaign->method('getMetadata')->willReturn($metadata); $campaign->method('getId')->willReturn(123); @@ -234,15 +271,17 @@ public function testInvokeWithMailerException(): void ->with(123, MessageStatus::Submitted) ->willReturn($campaign); + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $this->cache->method('get')->willReturn($precached); + $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getEmail')->willReturn('test@example.com'); $subscriber->method('getId')->willReturn(1); - $this->precacheService->expects($this->once()) - ->method('getOrCacheBaseMessageContent') - ->with($campaign) - ->willReturn($content); - $this->subscriberProvider->expects($this->once()) ->method('getSubscribersForMessage') ->with($campaign) @@ -250,8 +289,21 @@ public function testInvokeWithMailerException(): void $this->messagePreparator->expects($this->once()) ->method('processMessageLinks') - ->with(123, $content, $subscriber) - ->willReturn($content); + ->with(123, $precached, $subscriber) + ->willReturn($precached); + + // Build email and throw on rate-limited sender + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty('campaignEmailBuilder'); + + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); + $campaignBuilderMock->expects($this->once()) + ->method('buildPhplistEmail') + ->willReturn([ + (new Email())->to('test@example.com')->subject('Test Subject')->text('x'), + OutputFormat::Text + ]); $exception = new Exception('Test exception'); $this->mailer->expects($this->once()) @@ -277,7 +329,11 @@ public function testInvokeWithMailerException(): void public function testInvokeWithMultipleSubscribers(): void { $campaign = $this->createCampaignMock(); - $content = $this->createContentMock(); + $precached = new MessagePrecacheDto(); + $precached->subject = 'Test Subject'; + $precached->content = '

Test HTML message

'; + $precached->textContent = 'Test text message'; + $precached->footer = 'Test footer message'; $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); $campaign->method('getId')->willReturn(1); @@ -286,6 +342,13 @@ public function testInvokeWithMultipleSubscribers(): void ->with(1, MessageStatus::Submitted) ->willReturn($campaign); + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $this->cache->method('get')->willReturn($precached); + $subscriber1 = $this->createMock(Subscriber::class); $subscriber1->method('getEmail')->willReturn('test1@example.com'); $subscriber1->method('getId')->willReturn(1); @@ -305,7 +368,29 @@ public function testInvokeWithMultipleSubscribers(): void $this->messagePreparator->expects($this->exactly(2)) ->method('processMessageLinks') - ->willReturn($content); + ->withConsecutive( + [1, $precached, $subscriber1], + [1, $precached, $subscriber2] + ) + ->willReturnOnConsecutiveCalls($precached, $precached); + + // Configure builder to return emails for first two subscribers + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty('campaignEmailBuilder'); + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); + $campaignBuilderMock->expects($this->exactly(2)) + ->method('buildPhplistEmail') + ->willReturnOnConsecutiveCalls( + [ + (new Email())->to('test1@example.com')->subject('Test Subject')->text('x'), + OutputFormat::Text + ], + [ + (new Email())->to('test2@example.com')->subject('Test Subject')->text('x'), + OutputFormat::Text + ], + ); $this->mailer->expects($this->exactly(2)) ->method('send'); diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php index 6288c5f4..2a4e1ed4 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php @@ -4,13 +4,13 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\MessageHandler; +use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PHPUnit\Framework\TestCase; use PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler; use PhpList\Core\Domain\Messaging\Service\EmailService; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use Psr\Log\LoggerInterface; @@ -26,14 +26,14 @@ public function testSendsEmailWithPersonalizedContentAndListNames(): void $emailService = $this->createMock(EmailService::class); $configProvider = $this->createMock(ConfigProvider::class); $logger = $this->createMock(LoggerInterface::class); - $personalizer = $this->createMock(UserPersonalizer::class); + $userPersonalizer = $this->createMock(UserPersonalizer::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + userPersonalizer: $userPersonalizer, subscriberListRepository: $listRepo ); $configProvider @@ -44,11 +44,15 @@ public function testSendsEmailWithPersonalizedContentAndListNames(): void [ConfigOption::SubscribeMessage, 'Hi {{name}}, you subscribed to: [LISTS]'], ]); - $message = new SubscriptionConfirmationMessage('alice@example.com', 'user-123', [10, 11]); + $message = new SubscriptionConfirmationMessage( + email: 'alice@example.com', + uniqueId: 'user-123', + listIds: [10, 11], + ); - $personalizer->expects($this->once()) + $userPersonalizer->expects($this->once()) ->method('personalize') - ->with('Hi {{name}}, you subscribed to: [LISTS]', 'user-123') + ->with('Hi {{name}}, you subscribed to: [LISTS]', 'alice@example.com') ->willReturn('Hi Alice, you subscribed to: [LISTS]'); $listA = $this->createMock(SubscriberList::class); @@ -95,14 +99,14 @@ public function testHandlesMissingListsGracefullyAndEmptyJoin(): void $emailService = $this->createMock(EmailService::class); $configProvider = $this->createMock(ConfigProvider::class); $logger = $this->createMock(LoggerInterface::class); - $personalizer = $this->createMock(UserPersonalizer::class); + $userPersonalizer = $this->createMock(UserPersonalizer::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + userPersonalizer: $userPersonalizer, subscriberListRepository: $listRepo ); @@ -117,11 +121,11 @@ public function testHandlesMissingListsGracefullyAndEmptyJoin(): void $message->method('getUniqueId')->willReturn('user-456'); $message->method('getListIds')->willReturn([42]); - $personalizer->method('personalize') - ->with('Lists: [LISTS]', 'user-456') + $userPersonalizer->method('personalize') + ->with('Lists: [LISTS]', 'bob@example.com') ->willReturn('Lists: [LISTS]'); - $listRepo->method('find')->with(42)->willReturn(null); + $listRepo->method('getListNames')->with([42])->willReturn([]); $emailService->expects($this->once()) ->method('sendEmail') diff --git a/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php new file mode 100644 index 00000000..9356eb3d --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php @@ -0,0 +1,234 @@ +createMock(CacheInterface::class); + // default: firstTime returns true once per unique key + $cache->method('has')->willReturn(false); + $cache->method('set')->willReturn(true); + $onceCacheGuard = new OnceCacheGuard($cache); + + return new AttachmentAdder( + attachmentRepository: $this->attachmentRepository, + translator: $this->translator, + eventLogManager: $this->eventLogManager, + onceCacheGuard: $onceCacheGuard, + fileHelper: $this->fileHelper, + attachmentDownloadUrl: $downloadUrl, + attachmentRepositoryPath: '/repo', + ); + } + + protected function setUp(): void + { + $this->attachmentRepository = $this->createMock(AttachmentRepository::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->fileHelper = $this->createMock(FileHelper::class); + + // default translator: return the message id itself for easier asserts + $this->translator + ->method('trans') + ->willReturnCallback(static fn(string $id, array $params = []) => $id); + } + + public function testAddReturnsTrueWhenNoAttachments(): void + { + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([]); + + $adder = $this->makeAdder(); + $email = (new Email())->to(new Address('user@example.com')); + + $this->assertTrue($adder->add($email, 123, OutputFormat::Text)); + $this->assertSame('', (string)$email->getTextBody()); + } + + public function testTextModePrependsNoticeAndLinks(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getId')->willReturn(42); + $att->method('getDescription')->willReturn('Doc description'); + $this->attachmentRepository->method('findAttachmentsForMessage')->willReturn([$att]); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(downloadUrl: 'https://dl.example'); + + $ok = $adder->add($email, 10, OutputFormat::Text); + $this->assertTrue($ok); + + $body = (string)$email->getTextBody(); + $this->assertStringContainsString( + 'This message contains attachments that can be viewed with a webbrowser', + $body + ); + $this->assertStringContainsString('Doc description', $body); + $this->assertStringContainsString('Location', $body); + $this->assertStringContainsString('https://dl.example/?id=42&uid=user@example.com', $body); + } + + public function testHtmlUsesRepositoryFileIfExists(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn('stored/file.pdf'); + $att->method('getRemoteFile')->willReturn('/originals/file.pdf'); + $att->method('getMimeType')->willReturn('application/pdf'); + $att->method('getSize')->willReturn(10); + + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([$att]); + + // repository path file exists and can be read + $this->fileHelper + ->method('isValidFile') + ->willReturnCallback( + function (string $path): bool { + return $path === '/repo/stored/file.pdf'; + } + ); + $this->fileHelper + ->method('readFileContents') + ->willReturnCallback( + function (string $path): ?string { + return $path === '/repo/stored/file.pdf' ? 'PDF-DATA' : null; + } + ); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $ok = $adder->add($email, 77, OutputFormat::Html); + $this->assertTrue($ok); + + $attachments = $email->getAttachments(); + $this->assertCount(1, $attachments); + $this->assertSame('file.pdf', $attachments[0]->getFilename()); + } + + public function testHtmlLocalFileUnreadableLogsAndReturnsFalse(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn(null); + $att->method('getRemoteFile')->willReturn('/local/missing.txt'); + $att->method('getMimeType')->willReturn('text/plain'); + $att->method('getSize')->willReturn(10); + + $this->attachmentRepository->method('findAttachmentsForMessage')->willReturn([$att]); + + // Not in repository; local path considered valid file, but cannot be read + $this->fileHelper->method('isValidFile')->willReturn(true); + $this->fileHelper->method('readFileContents')->willReturn(null); + + $this->eventLogManager->expects($this->once())->method('log'); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $ok = $adder->add($email, 501, OutputFormat::Html); + $this->assertFalse($ok); + $this->assertCount(0, $email->getAttachments()); + } + + public function testCopyFailureThrowsOnFirstTime(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn(null); + $att->method('getRemoteFile')->willReturn('/local/ok.pdf'); + $att->method('getMimeType')->willReturn('application/pdf'); + $att->method('getSize')->willReturn(10); + + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([$att]); + + // Repository path should not exist, local file should be readable + $this->fileHelper + ->method('isValidFile') + ->willReturnCallback( + function (string $path): bool { + if ($path === '/repo/') { + // repository lookup should fail + return false; + } + return $path === '/local/ok.pdf'; + } + ); + $this->fileHelper + ->method('readFileContents') + ->willReturnCallback( + function (string $path): ?string { + return $path === '/local/ok.pdf' ? 'PDF' : null; + } + ); + // copy to repository fails + $this->fileHelper + ->method('writeFileToDirectory') + ->willReturn(null); + + $this->eventLogManager + ->expects($this->once()) + ->method('log'); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $this->expectException(AttachmentCopyException::class); + $adder->add($email, 321, OutputFormat::Html); + } + + public function testMissingAttachmentThrowsOnFirstTime(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn(null); + $att->method('getRemoteFile')->willReturn('/local/not-exist.bin'); + $att->method('getMimeType')->willReturn('application/octet-stream'); + $att->method('getSize')->willReturn(5); + + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([$att]); + + // Not in repository; local path invalid -> missing + $this->fileHelper + ->method('isValidFile') + ->willReturn(false); + + $this->eventLogManager + ->expects($this->once()) + ->method('log'); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $this->expectException(AttachmentCopyException::class); + $adder->add($email, 999, OutputFormat::Html); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php new file mode 100644 index 00000000..900ada89 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -0,0 +1,420 @@ +configProvider = $this->createMock(ConfigProvider::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->blacklistRepository = $this->createMock(UserBlacklistRepository::class); + $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->mailConstructor = $this->getMockBuilder(MailContentBuilderInterface::class) + ->onlyMethods(['__invoke']) + ->getMock(); + $this->templateImageEmbedder = $this->getMockBuilder(TemplateImageEmbedder::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->pdfGenerator = $this->createMock(PdfGenerator::class); + $this->attachmentAdder = $this->createMock(AttachmentAdder::class); + $this->translator = $this->createMock(TranslatorInterface::class); + + $this->configProvider->method('getValue')->willReturnMap( + [ + [ConfigOption::MessageFromAddress, 'from@example.com'], + [ConfigOption::MessageFromName, 'From Name'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], + [ConfigOption::PreferencesUrl, 'https://example.com/prefs'], + [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'], + [ConfigOption::AdminAddress, 'admin@example.com'], + [ConfigOption::AlwaysSendTextDomains, ''], + ] + ); + } + + private function makeBuilder( + string $googleSenderId = 'g-123', + bool $useAmazonSes = false, + bool $usePrecedenceHeader = true, + bool $devVersion = true, + ?string $devEmail = 'dev@example.com', + ): EmailBuilder { + return new EmailBuilder( + configProvider: $this->configProvider, + eventLogManager: $this->eventLogManager, + blacklistRepository: $this->blacklistRepository, + subscriberHistoryManager: $this->subscriberHistoryManager, + subscriberRepository: $this->subscriberRepository, + logger: $this->logger, + mailConstructor: $this->mailConstructor, + templateImageEmbedder: $this->templateImageEmbedder, + urlBuilder: $this->urlBuilder, + pdfGenerator: $this->pdfGenerator, + attachmentAdder: $this->attachmentAdder, + translator: $this->translator, + googleSenderId: $googleSenderId, + useAmazonSes: $useAmazonSes, + usePrecedenceHeader: $usePrecedenceHeader, + devVersion: $devVersion, + devEmail: $devEmail, + ); + } + + public function testReturnsNullWhenMissingRecipient(): void + { + $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = null; + $dto->subject = 'Subj'; + $dto->content = 'Body'; + $dto->fromEmail = 'from@example.com'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $this->assertNull($result); + } + + public function testReturnsNullWhenMissingSubject(): void + { + $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Body'; + $dto->fromEmail = 'from@example.com'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $this->assertNull($result); + } + + public function testBlacklistReturnsNullAndMarksHistory(): void + { + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(true); + + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['setBlacklisted']) + ->getMock(); + $subscriber + ->expects($this->once()) + ->method('setBlacklisted') + ->with(true); + $this->subscriberRepository + ->method('findOneByEmail') + ->with('user@example.com') + ->willReturn($subscriber); + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory'); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Hello'; + $dto->content = 'B'; + $dto->fromEmail = 'from@example.com'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 5, data: $dto); + $this->assertNull($result); + } + + public function testBuildsHtmlPreferredWithAttachments(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'real@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->fromName = 'From Name'; + + $this->mailConstructor + ->expects($this->once()) + ->method('__invoke') + ->with($dto) + ->willReturn(['

HTML

', 'TEXT']); + $this->templateImageEmbedder + ->expects($this->once()) + ->method('__invoke') + ->with(html: '

HTML

', messageId: 777) + ->willReturn('

HTML

'); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 777, OutputFormat::Html) + ->willReturn(true); + + $builder = $this->makeBuilder(devVersion: true, devEmail: 'dev@example.com'); + [$email, $sentAs] = $builder->buildPhplistEmail( + messageId: 777, + data: $dto, + skipBlacklistCheck: false, + inBlast: true, + htmlPref: false, + ); + + $this->assertSame(OutputFormat::TextAndHtml, $sentAs); + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertSame('

HTML

', $email->getHtmlBody()); + + // Recipient redirected in dev mode + $this->assertSame('dev@example.com', $email->getTo()[0]->getAddress()); + $this->assertSame('real@example.com', $email->getHeaders()->get('X-Originally-To')->getBodyAsString()); + } + + public function testPrefersTextWhenNoHtmlContent(): void + { + $this->configProvider + ->method('getValue') + ->willReturnMap([ + [ConfigOption::MessageFromAddress, 'from@example.com'], + [ConfigOption::MessageFromName, 'From Name'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], + [ConfigOption::PreferencesUrl, 'https://example.com/prefs'], + [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'], + [ConfigOption::AdminAddress, 'admin@example.com'], + [ConfigOption::AlwaysSendTextDomains, ''], + ]); + + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + + // No HTML content provided -> should choose text-only + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 9, OutputFormat::Text) + ->willReturn(true); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 9, data: $dto, htmlPref: true); + + $this->assertSame(OutputFormat::Text, $sentAs); + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertNull($email->getHtmlBody()); + $this->assertSame('user@example.com', $email->getTo()[0]->getAddress()); + } + + public function testPdfFormatWhenHtmlPreferred(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->sendFormat = 'pdf'; + + $this->mailConstructor + ->method('__invoke') + ->willReturn(['H', 'TEXT']); + $this->pdfGenerator + ->expects($this->once()) + ->method('createPdfBytes') + ->with('TEXT') + ->willReturn('%PDF%'); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 42, OutputFormat::Html) + ->willReturn(true); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 42, data: $dto, htmlPref: true); + + $this->assertSame(OutputFormat::Pdf, $sentAs); + $this->assertCount(1, $email->getAttachments()); + } + + public function testTextAndPdfFormatWhenNotHtmlPreferred(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->sendFormat = 'text and pdf'; + + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 43, OutputFormat::Text) + ->willReturn(true); + $this->pdfGenerator + ->expects($this->never()) + ->method('createPdfBytes'); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 43, data: $dto, htmlPref: false); + + $this->assertSame(OutputFormat::Text, $sentAs); + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertCount(0, $email->getAttachments()); + } + + public function testReplyToExplicitAndTestMailFallback(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + + // explicit reply-to + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->replyToEmail = 'reply@example.com'; + $dto->replyToName = 'Rep'; + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + $this->attachmentAdder + ->method('add') + ->willReturn(true); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email] = $builder->buildPhplistEmail(messageId: 50, data: $dto); + $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress()); + + // no reply-to, but test mail -> uses AdminAddress + $dto2 = new MessagePrecacheDto(); + $dto2->to = 'user@example.com'; + $dto2->subject = 'Subject'; + $dto2->content = 'TEXT'; + $dto2->fromEmail = 'from@example.com'; + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + + $this->translator + ->method('trans') + ->with('(test)') + ->willReturn('(test)'); + + [$email2] = $builder->buildPhplistEmail(messageId: 51, data: $dto2, isTestMail: true); + $this->assertSame('admin@example.com', $email2->getReplyTo()[0]->getAddress()); + $this->assertStringStartsWith('(test) ', $email2->getSubject()); + } + + public function testApplyCampaignHeaders(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getUniqueId']) + ->getMock(); + $subscriber + ->method('getUniqueId') + ->willReturn('abc123'); + + $this->urlBuilder + ->method('withUid') + ->willReturnCallback( + function (string $url, string $uid): string { + return $url . '?uid=' . $uid; + } + ); + + $builder = $this->makeBuilder(); + $email = (new Email())->to(new Address('user@example.com')); + $email = $builder->applyCampaignHeaders($email, $subscriber); + + $headers = $email->getHeaders(); + $this->assertSame('', $headers->get('List-Help')->getBodyAsString()); + $this->assertSame( + '', + $headers->get('List-Unsubscribe')->getBodyAsString() + ); + $this->assertSame('List-Unsubscribe=One-Click', $headers->get('List-Unsubscribe-Post')->getBodyAsString()); + $this->assertSame('', $headers->get('List-Subscribe')->getBodyAsString()); + // In implementation, adminAddress uses UnsubscribeUrl option (likely a bug); we assert the behavior as-is + $this->assertSame('', $headers->get('List-Owner')->getBodyAsString()); + } + + public function testAttachmentAdderFailureThrows(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + + $this->mailConstructor + ->method('__invoke') + ->willReturn(['H', 'TEXT']); + $this->templateImageEmbedder + ->method('__invoke') + ->willReturn('H'); + $this->attachmentAdder + ->method('add') + ->willReturn(false); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + + $this->expectException(AttachmentException::class); + $builder->buildPhplistEmail(messageId: 60, data: $dto, htmlPref: true); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php index 17d93eae..ed4645ed 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php @@ -25,7 +25,6 @@ public function testBuildsMessageFormatSuccessfully(): void $this->assertSame(true, $messageFormat->isHtmlFormatted()); $this->assertSame('html', $messageFormat->getSendFormat()); - $this->assertEqualsCanonicalizing(['html', 'text'], $messageFormat->getFormatOptions()); } public function testThrowsExceptionOnInvalidDto(): void diff --git a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php new file mode 100644 index 00000000..d1b891c2 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php @@ -0,0 +1,205 @@ +configProvider = $this->createMock(ConfigProvider::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->blacklistRepository = $this->createMock(UserBlacklistRepository::class); + $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->mailConstructor = $this->getMockBuilder(MailContentBuilderInterface::class) + ->onlyMethods(['__invoke']) + ->getMock(); + $this->templateImageEmbedder = $this->getMockBuilder(TemplateImageEmbedder::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->configProvider->method('getValue')->willReturnMap( + [ + [ConfigOption::MessageFromAddress, 'from@example.com'], + [ConfigOption::MessageFromName, 'From Name'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], + ] + ); + } + + private function makeBuilder( + string $googleSenderId = 'g-123', + bool $useAmazonSes = false, + bool $usePrecedenceHeader = true, + bool $devVersion = true, + ?string $devEmail = 'dev@example.com', + ): SystemEmailBuilder { + return new SystemEmailBuilder( + configProvider: $this->configProvider, + eventLogManager: $this->eventLogManager, + blacklistRepository: $this->blacklistRepository, + subscriberHistoryManager: $this->subscriberHistoryManager, + subscriberRepository: $this->subscriberRepository, + mailConstructor: $this->mailConstructor, + templateImageEmbedder: $this->templateImageEmbedder, + logger: $this->logger, + googleSenderId: $googleSenderId, + useAmazonSes: $useAmazonSes, + usePrecedenceHeader: $usePrecedenceHeader, + devVersion: $devVersion, + devEmail: $devEmail, + ); + } + + public function testReturnsNullWhenMissingRecipient(): void + { + $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = null; + $dto->subject = 'Subj'; + $dto->content = 'Body'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $this->assertNull($result); + } + + public function testReturnsNullWhenMissingSubject(): void + { + $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Body'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $this->assertNull($result); + } + + public function testReturnsNullWhenBlacklistedAndHistoryUpdated(): void + { + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(true); + + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['setBlacklisted']) + ->getMock(); + $subscriber->expects($this->once()) + ->method('setBlacklisted') + ->with(true); + $this->subscriberRepository + ->method('findOneByEmail') + ->with('user@example.com') + ->willReturn($subscriber); + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory'); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Hello'; + $dto->content = 'B'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 5, data: $dto); + $this->assertNull($result); + } + + public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'real@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + + $this->mailConstructor->expects($this->once()) + ->method('__invoke') + ->with($dto) + ->willReturn(['

HTML

', 'TEXT']); + + $this->templateImageEmbedder->expects($this->once()) + ->method('__invoke') + ->with(html: '

HTML

', messageId: 777) + ->willReturn('

HTML

'); + + $builder = $this->makeBuilder( + googleSenderId: 'g-123', + useAmazonSes: false, + usePrecedenceHeader: true, + devVersion: true, + devEmail: 'dev@example.com' + ); + + $email = $builder->buildPhplistEmail( + messageId: 777, + data: $dto, + skipBlacklistCheck: false, + inBlast: true + ); + + $this->assertNotNull($email); + + // Recipient is redirected to dev email in dev mode + $this->assertCount(1, $email->getTo()); + $this->assertInstanceOf(Address::class, $email->getTo()[0]); + $this->assertSame('dev@example.com', $email->getTo()[0]->getAddress()); + + // Headers + $headers = $email->getHeaders(); + $this->assertSame('777', $headers->get('X-MessageID')->getBodyAsString()); + $this->assertSame('dev@example.com', $headers->get('X-ListMember')->getBodyAsString()); + $this->assertSame('777:g-123', $headers->get('Feedback-ID')->getBodyAsString()); + $this->assertSame('bulk', $headers->get('Precedence')->getBodyAsString()); + $this->assertSame('1', $headers->get('X-Blast')->getBodyAsString()); + + $this->assertTrue($headers->has('X-Originally-To')); + $this->assertSame('real@example.com', $headers->get('X-Originally-To')->getBodyAsString()); + + $this->assertTrue($headers->has('List-Unsubscribe')); + $this->assertStringContainsString( + 'email=dev%40example.com', + $headers->get('List-Unsubscribe')->getBodyAsString() + ); + + // From and subject + $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress()); + $this->assertSame('From Name', $email->getFrom()[0]->getName()); + $this->assertSame('Subject', $email->getSubject()); + + // Bodies + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertSame('

HTML

', $email->getHtmlBody()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php new file mode 100644 index 00000000..84ace526 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php @@ -0,0 +1,265 @@ +subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->remotePageFetcher = $this->getMockBuilder(RemotePageFetcher::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->configProvider = $this->createMock(ConfigProvider::class); + $this->html2Text = $this->getMockBuilder(Html2Text::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->textParser = $this->getMockBuilder(TextParser::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->placeholderProcessor = $this->createMock(MessagePlaceholderProcessor::class); + + $this->configProvider + ->method('getValue') + ->willReturnMap( + [ + [ConfigOption::HtmlEmailStyle, ''], + ] + ); + } + + private function makeBuilder(): CampaignMailContentBuilder + { + return new CampaignMailContentBuilder( + subscriberRepository: $this->subscriberRepository, + remotePageFetcher: $this->remotePageFetcher, + eventLogManager: $this->eventLogManager, + configProvider: $this->configProvider, + html2Text: $this->html2Text, + textParser: $this->textParser, + placeholderProcessor: $this->placeholderProcessor, + ); + } + + public function testThrowsWhenSubscriberNotFound(): void + { + $dto = new MessagePrecacheDto(); + $dto->to = 'missing@example.com'; + $dto->content = 'Hello'; + + $this->subscriberRepository->method('findOneByEmail')->willReturn(null); + + $builder = $this->makeBuilder(); + $this->expectException(SubscriberNotFoundException::class); + $builder($dto, 10); + } + + public function testBuildsHtmlFormattedGeneratesTextViaHtml2Text(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(123); + $subscriber->method('getEmail')->willReturn('user@example.com'); + + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Hi'; + $dto->htmlFormatted = true; + + $this->html2Text->expects($this->once()) + ->method('__invoke') + ->with('Hi') + ->willReturn('Hi'); + + $builder = $this->makeBuilder(); + [$html, $text] = $builder($dto, 5); + + $this->assertSame('Hi', $text); + $this->assertStringContainsString('Hi', $html); + $this->assertStringContainsString('assertStringContainsString('', $html); + $this->assertStringContainsString( + '/*default-style*/', + $html, + 'Default style should be added when no template is used' + ); + } + + public function testBuildsFromPlainTextUsingTextParser(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(22); + $subscriber->method('getEmail')->willReturn('user@example.com'); + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Hello world'; + $dto->htmlFormatted = false; + + $this->textParser->expects($this->once()) + ->method('__invoke') + ->with('Hello world') + ->willReturn('

Hello world

'); + + $builder = $this->makeBuilder(); + [$html, $text] = $builder($dto, 7); + + $this->assertSame('Hello world', $text); + $this->assertStringContainsString('

Hello world

', $html); + $this->assertStringContainsString('/*default-style*/', $html); + } + + public function testUserSpecificUrlReplacementAndExceptionOnEmpty(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(55); + $subscriber->method('getEmail')->willReturn('user@example.com'); + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + $this->subscriberRepository + ->method('getDataById') + ->with(55) + ->willReturn(['id' => 55]); + + // Success path replacement + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Intro [URL:example.com/path] End'; + $dto->userSpecificUrl = true; + + $this->remotePageFetcher + ->expects($this->exactly(2)) + ->method('__invoke') + ->withConsecutive( + ['https://example.com/path', ['id' => 55]], + ['https://example.com/empty', ['id' => 55]], + ) + ->willReturnOnConsecutiveCalls('
REMOTE
', ''); + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $builder = $this->makeBuilder(); + [$html] = $builder($dto, 11); + $this->assertStringContainsString('
REMOTE
', $html); + + // Failure path (empty content) should log and throw + $dto2 = new MessagePrecacheDto(); + $dto2->to = 'user@example.com'; + $dto2->content = 'Again [URL:example.com/empty] test'; + $dto2->userSpecificUrl = true; + + $this->eventLogManager + ->expects($this->once()) + ->method('log'); + + $this->expectException(RemotePageFetchException::class); + $builder($dto2, 12); + } + + public function testTemplatePreventsDefaultStyleInjection(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(77); + $subscriber->method('getEmail')->willReturn('user@example.com'); + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = '

Inner

'; + $dto->htmlFormatted = true; + $dto->template = 'TBEFORE[CONTENT]AFTER'; + + $builder = $this->makeBuilder(); + [$html, $text] = $builder($dto, 2); + + $this->assertStringContainsString('BEFORE

Inner

AFTER', $html); + $this->assertStringNotContainsString( + '/*default-style*/', + $html, + 'Default style must not be added when template provided' + ); + $this->assertSame( + '', + $text, + 'No text content provided and html2text not used when htmlFormatted and template present' + ); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php b/tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php new file mode 100644 index 00000000..e8ba227e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php @@ -0,0 +1,171 @@ +eventLogManager = $this->createMock(EventLogManager::class); + $this->messageDataManager = $this->createMock(MessageDataManager::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + private function createMessageWithId(int $id): Message + { + $message = $this->getMockBuilder(Message::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId']) + ->getMock(); + + $message->method('getId')->willReturn($id); + + return $message; + } + + private function createEmail(): Email + { + return (new Email()) + ->from('no-reply@example.com') + ->to('user@example.com') + ->subject('Subject') + ->text('Body'); + } + + public function testDisabledMaxMailSizeDoesNothingAndSkipsCache(): void + { + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 0, + ); + + $this->cache->expects($this->never())->method('has'); + $this->messageDataManager->expects($this->never())->method('setMessageData'); + $this->eventLogManager->expects($this->never())->method('log'); + $this->logger->expects($this->never())->method('warning'); + + $checker->__invoke($this->createMessageWithId(1), $this->createEmail(), true); + // No exceptions + $this->addToAssertionCount(1); + } + + public function testCacheMissCalculatesAndStoresAndDoesNotThrow(): void + { + // very large to avoid throwing regardless of calculated size + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 10_000_000, + ); + + $message = $this->createMessageWithId(42); + + $this->cache->expects($this->once()) + ->method('has') + ->with($this->callback(fn (string $key) => str_contains($key, 'messaging.size.42.htmlsize'))) + ->willReturn(false); + + $this->messageDataManager->expects($this->once()) + ->method('setMessageData') + ->with($message, 'htmlsize', $this->callback(fn ($v) => is_int($v) && $v > 0)); + + $this->cache->expects($this->once()) + ->method('set') + ->with( + $this->callback(fn (string $key) => str_contains($key, 'messaging.size.42.htmlsize')), + $this->callback(fn ($v) => is_int($v) && $v > 0) + ); + + // After setting, get() will be called; return a small size to keep below limit + $this->cache->expects($this->once()) + ->method('get') + ->with($this->callback(fn (string $key) => str_contains($key, 'messaging.size.42.htmlsize'))) + ->willReturn(100); + + $checker->__invoke($message, $this->createEmail(), true); + $this->addToAssertionCount(1); + } + + public function testThrowsWhenCachedSizeExceedsLimitAndLogsAndEvents(): void + { + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 500, + ); + + $message = $this->createMessageWithId(7); + + // Simulate cache hit with a large size + $this->cache->method('has')->willReturn(true); + $this->cache->method('get')->willReturn(1_000); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->callback( + fn (string $msg) => str_contains($msg, 'Message too large') && str_contains($msg, '7') + )); + + $this->eventLogManager->expects($this->exactly(2)) + ->method('log') + ->with( + 'send', + $this->callback( + fn (string $msg) => + str_contains($msg, 'Message too large') || str_contains($msg, 'Campaign 7 suspended') + ) + ); + + $this->expectException(MessageSizeLimitExceededException::class); + $checker->__invoke($message, $this->createEmail(), false); + } + + public function testReturnsWhenCachedSizeWithinLimit(): void + { + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 10_000, + ); + + $message = $this->createMessageWithId(99); + + $this->cache->method('has')->willReturn(true); + // well below the limit + $this->cache->method('get')->willReturn(123); + + $this->logger->expects($this->never())->method('warning'); + $this->eventLogManager->expects($this->never())->method('log'); + + $checker->__invoke($message, $this->createEmail(), true); + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index 932e0d8a..238bcd06 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; -use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Model\TemplateImage; use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository; @@ -15,17 +15,17 @@ class TemplateImageManagerTest extends TestCase { private TemplateImageRepository&MockObject $templateImageRepository; - private EntityManagerInterface&MockObject $entityManager; + private ConfigProvider&MockObject $configProvider; private TemplateImageManager $manager; protected function setUp(): void { $this->templateImageRepository = $this->createMock(TemplateImageRepository::class); - $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->configProvider = $this->createMock(ConfigProvider::class); $this->manager = new TemplateImageManager( templateImageRepository: $this->templateImageRepository, - entityManager: $this->entityManager + configProvider: $this->configProvider, ); } @@ -33,7 +33,7 @@ public function testCreateImagesFromImagePaths(): void { $template = $this->createMock(Template::class); - $this->entityManager->expects($this->exactly(2)) + $this->templateImageRepository->expects($this->exactly(2)) ->method('persist') ->with($this->isInstanceOf(TemplateImage::class)); diff --git a/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php b/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php new file mode 100644 index 00000000..c6174a44 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php @@ -0,0 +1,140 @@ +config = $this->createMock(ConfigProvider::class); + $this->messageDataRepository = $this->createMock(MessageDataRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + } + + public function testLoadsMessageDataMergesAndParses(): void + { + $defaultMessageAge = 3600; + + $this->config->method('getValue')->willReturnMap([ + [ConfigOption::MessageFromAddress, 'from@example.com'], + [ConfigOption::AdminAddress, 'admin@example.com'], + [ConfigOption::DefaultMessageTemplate, '123'], + [ConfigOption::MessageFooter, 'footer'], + [ConfigOption::ForwardFooter, 'ffooter'], + [ConfigOption::NotifyStartDefault, 'start@example.com'], + [ConfigOption::NotifyEndDefault, 'end@example.com'], + [ConfigOption::AlwaysAddGoogleTracking, '1'], + ]); + + $messageId = 10; + + // Non-empty fields from MessageRepository + $this->messageRepository + ->method('getNonEmptyFields') + ->with($messageId) + ->willReturn([ + 'subject' => '(no title)', + 'message' => 'Hello [URL:https://example.org/p]', + 'fromfield' => '', + ]); + + // Stored message data rows (repository) + $md1 = (new MessageData())->setId($messageId)->setName('ashtml')->setData('1'); + $md2 = (new MessageData())->setId($messageId)->setName('criteria_match')->setData('any'); + $md3 = (new MessageData())->setId($messageId)->setName('embargo')->setData('string'); + + $this->messageDataRepository + ->method('getForMessage') + ->with($messageId) + ->willReturn([$md1, $md2, $md3]); + + // Use a Message mock instead of an anonymous stub + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getListMessages')->willReturn( + new ArrayCollection([ + new class { + public function getListId(): int + { + return 42; + } + }, + ]) + ); + + $loader = new MessageDataLoader( + configProvider: $this->config, + messageDataRepository: $this->messageDataRepository, + messageRepository: $this->messageRepository, + logger: $this->createMock(LoggerInterface::class), + defaultMessageAge: $defaultMessageAge + ); + + $before = time(); + $result = ($loader)($message); + $after = time(); + + // Core expectations + $this->assertSame('123', $result['template']); + $this->assertTrue($result['google_track']); + + // subject mapping + $this->assertSame('(no subject)', $result['subject']); + + // stored data merged (and AS_FORMAT_FIELDS ignored) + $this->assertSame('any', $result['criteria_match']); + $this->assertArrayNotHasKey('ashtml', $result, 'ashtml should not overwrite values'); + + // schedule fields normalized to arrays when not arrays + $this->assertIsArray($result['embargo']); + $this->assertIsArray($result['repeatuntil']); + $this->assertIsArray($result['requeueuntil']); + + // target list from message listMessages + $this->assertArrayHasKey(42, $result['targetlist']); + $this->assertSame(1, $result['targetlist'][42]); + + // sendurl inferred from message body + $this->assertSame('https://example.org/p', $result['sendurl']); + $this->assertSame('inputhere', $result['sendmethod']); + + // From parsing defaults + $this->assertSame('from@example.com', $result['fromemail']); + $this->assertSame('from@example.com', $result['fromname']); + + // finishsending should be now + defaultMessageAge (allow small drift) + $fs = $result['finishsending']; + $this->assertIsArray($fs); + $fsTimestamp = strtotime(sprintf( + '%s-%s-%s %s:%s:00', + $fs['year'], + $fs['month'], + $fs['day'], + $fs['hour'], + $fs['minute'] + )); + + $expectedMin = $before + $defaultMessageAge - 120; + $expectedMax = $after + $defaultMessageAge + 120; + $this->assertGreaterThanOrEqual($expectedMin, $fsTimestamp); + $this->assertLessThanOrEqual($expectedMax, $fsTimestamp); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php index b7530895..4cd2e800 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -6,8 +6,7 @@ use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; -use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -23,7 +22,6 @@ class MessageProcessingPreparatorTest extends TestCase private SubscriberRepository&MockObject $subscriberRepository; private MessageRepository&MockObject $messageRepository; private LinkTrackService&MockObject $linkTrackService; - private UserPersonalizer&MockObject $userPersonalizer; private OutputInterface&MockObject $output; private MessageProcessingPreparator $preparator; @@ -32,13 +30,6 @@ protected function setUp(): void $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->messageRepository = $this->createMock(MessageRepository::class); $this->linkTrackService = $this->createMock(LinkTrackService::class); - $this->userPersonalizer = $this->createMock(UserPersonalizer::class); - // Ensure personalization returns original text so assertions on replaced links remain valid - $this->userPersonalizer - ->method('personalize') - ->willReturnCallback(function (string $text) { - return $text; - }); $this->output = $this->createMock(OutputInterface::class); $this->preparator = new MessageProcessingPreparator( @@ -46,7 +37,6 @@ protected function setUp(): void messageRepository: $this->messageRepository, linkTrackService: $this->linkTrackService, translator: new Translator('en'), - userPersonalizer: $this->userPersonalizer, ); } @@ -128,7 +118,7 @@ public function testEnsureCampaignsHaveUuidWithCampaigns(): void public function testProcessMessageLinksWhenLinkTrackingNotApplicable(): void { - $messageContent = $this->createMock(MessageContent::class); + $messageContent = new MessagePrecacheDto(); $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getId')->willReturn(123); $subscriber->method('getEmail')->willReturn('test@example.com'); @@ -140,9 +130,6 @@ public function testProcessMessageLinksWhenLinkTrackingNotApplicable(): void $this->linkTrackService->expects($this->never()) ->method('extractAndSaveLinks'); - $messageContent->expects($this->never()) - ->method('getText'); - $result = $this->preparator->processMessageLinks(1, $messageContent, $subscriber); $this->assertSame($messageContent, $result); @@ -151,7 +138,7 @@ public function testProcessMessageLinksWhenLinkTrackingNotApplicable(): void public function testProcessMessageLinksWhenNoLinksExtracted(): void { $messageId = 1; - $messageContent = $this->createMock(MessageContent::class); + $messageContent = new MessagePrecacheDto(); $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getId')->willReturn(123); $subscriber->method('getEmail')->willReturn('test@example.com'); @@ -165,9 +152,6 @@ public function testProcessMessageLinksWhenNoLinksExtracted(): void ->with($messageContent, 123, $messageId) ->willReturn([]); - $messageContent->expects($this->never()) - ->method('getText'); - $result = $this->preparator->processMessageLinks($messageId, $messageContent, $subscriber); $this->assertSame($messageContent, $result); @@ -175,7 +159,7 @@ public function testProcessMessageLinksWhenNoLinksExtracted(): void public function testProcessMessageLinksWithLinksExtracted(): void { - $content = $this->createMock(MessageContent::class); + $content = new MessagePrecacheDto(); $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getId')->willReturn(123); $subscriber->method('getEmail')->willReturn('test@example.com'); @@ -196,22 +180,23 @@ public function testProcessMessageLinksWithLinksExtracted(): void ->with($content, 123, 1) ->willReturn($savedLinks); - $htmlContent = 'Link 1 Link 2'; - $content->method('getText')->willReturn($htmlContent); - - $footer = 'Footer Link'; - $content->method('getFooter')->willReturn($footer); - - $content->expects($this->once()) - ->method('setText') - ->with($this->stringContains(MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=1')); - - $content->expects($this->once()) - ->method('setFooter') - ->with($this->stringContains(MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=1')); + $content->content = 'Link 1 Link 2'; + $content->htmlFooter = 'Footer Link'; $result = $this->preparator->processMessageLinks(1, $content, $subscriber); $this->assertSame($content, $result); + $this->assertStringContainsString( + MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=1', + $content->content + ); + $this->assertStringContainsString( + MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=2', + $content->content + ); + $this->assertStringContainsString( + MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=1', + $content->htmlFooter + ); } } diff --git a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php index 97d4e158..d60b38e1 100644 --- a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php +++ b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php @@ -4,18 +4,10 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -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\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Messaging\Service\SendRateLimiter; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use ReflectionProperty; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; @@ -33,51 +25,6 @@ protected function setUp(): void $this->sut = new RateLimitedCampaignMailer($this->mailer, $this->limiter); } - public function testComposeEmailSetsHeadersAndBody(): void - { - $message = $this->buildMessage( - subject: 'Subject', - textBody: 'Plain text', - htmlBody: '

HTML

', - from: 'from@example.com', - replyTo: 'reply@example.com' - ); - - $subscriber = new Subscriber(); - $this->setSubscriberEmail($subscriber, 'user@example.com'); - - $email = $this->sut->composeEmail($message, $subscriber, $message->getContent()); - - $this->assertInstanceOf(Email::class, $email); - $this->assertSame('user@example.com', $email->getTo()[0]->getAddress()); - $this->assertSame('Subject', $email->getSubject()); - $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress()); - $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress()); - $this->assertSame('Plain text', $email->getTextBody()); - $this->assertSame('

HTML

', $email->getHtmlBody()); - } - - public function testComposeEmailWithoutOptionalHeaders(): void - { - $message = $this->buildMessage( - subject: 'No headers', - textBody: 'text', - htmlBody: 'h', - from: '', - replyTo: '' - ); - - $subscriber = new Subscriber(); - $this->setSubscriberEmail($subscriber, 'user2@example.com'); - - $email = $this->sut->composeEmail($message, $subscriber, $message->getContent()); - - $this->assertSame('user2@example.com', $email->getTo()[0]->getAddress()); - $this->assertSame('No headers', $email->getSubject()); - $this->assertSame([], $email->getFrom()); - $this->assertSame([], $email->getReplyTo()); - } - public function testSendUsesLimiterAroundMailer(): void { $email = (new Email())->to('someone@example.com'); @@ -91,44 +38,4 @@ public function testSendUsesLimiterAroundMailer(): void $this->sut->send($email); } - - private function buildMessage( - string $subject, - string $textBody, - string $htmlBody, - string $from, - string $replyTo - ): Message { - $content = new MessageContent( - subject: $subject, - text: $htmlBody, - textMessage: $textBody, - footer: null, - ); - $format = new MessageFormat( - htmlFormatted: true, - sendFormat: MessageFormat::FORMAT_HTML, - formatOptions: [MessageFormat::FORMAT_HTML] - ); - $schedule = new MessageSchedule( - repeatInterval: 0, - repeatUntil: null, - requeueInterval: 0, - requeueUntil: null, - embargo: null - ); - $metadata = new MessageMetadata(); - $options = new MessageOptions(fromField: $from, toField: '', replyTo: $replyTo); - - return new Message($format, $schedule, $metadata, $content, $options, null, null); - } - - /** - * Subscriber has no public setter for email, so we use reflection. - */ - private function setSubscriberEmail(Subscriber $subscriber, string $email): void - { - $ref = new ReflectionProperty($subscriber, 'email'); - $ref->setValue($subscriber, $email); - } } diff --git a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php new file mode 100644 index 00000000..8f7ee7f7 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php @@ -0,0 +1,182 @@ +html2Text = $this->getMockBuilder(Html2Text::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->configProvider = $this->createMock(ConfigProvider::class); + $this->templateRepository = $this->createMock(TemplateRepository::class); + $this->templateImageManager = $this->getMockBuilder(TemplateImageManager::class) + ->disableOriginalConstructor() + ->onlyMethods(['parseLogoPlaceholders']) + ->getMock(); + } + + private function createConstructor(bool $poweredByPhplist = false): SystemMailContentBuilder + { + // Defaults needed by constructor + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PoweredByText, 'Powered by phpList'], + [ConfigOption::SystemMessageTemplate, null], + ]); + + return new SystemMailContentBuilder( + html2Text: $this->html2Text, + configProvider: $this->configProvider, + templateRepository: $this->templateRepository, + templateImageManager: $this->templateImageManager, + poweredByPhplist: $poweredByPhplist, + ); + } + + public function testPlainTextWithoutTemplateLinkifiedAndNl2br(): void + { + $constructor = $this->createConstructor(); + + // Html2Text is not used when source is plain text + $this->html2Text->expects($this->never())->method('__invoke'); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = 'Line1' . "\n" . 'Visit http://example.com'; + + [$html, $text] = $constructor($dto); + + $this->assertSame("Line1\nVisit http://example.com", $text); + $this->assertStringContainsString('Line1assertStringContainsString('http://example.com', $html); + } + + public function testHtmlSourceWithoutTemplateUsesHtml2Text(): void + { + $constructor = $this->createConstructor(); + + $this->html2Text->expects($this->once()) + ->method('__invoke') + ->with('

Hello

') + ->willReturn('Hello'); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = '

Hello

'; + + [$html, $text] = $constructor($dto); + + $this->assertSame('

Hello

', $html); + $this->assertSame('Hello', $text); + } + + public function testTemplateWithSignaturePlaceholderUsesPoweredByImageWhenFlagFalse(): void + { + // Configure template usage + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PoweredByText, 'Powered'], + [ConfigOption::SystemMessageTemplate, '10'], + [ConfigOption::PoweredByImage, ''], + ]); + + $template = new Template('sys-template'); + $template->setContent('[SUBJECT]: [CONTENT] [SIGNATURE]'); + $template->setText("SUBJ: [SUBJECT]\n[BODY]\n[CONTENT]\n[SIGNATURE]"); + + $this->templateRepository->method('findOneById')->with(10)->willReturn($template); + + $this->templateImageManager->expects($this->once()) + ->method('parseLogoPlaceholders') + ->with($this->callback(fn ($html) => is_string($html))) + ->willReturnArgument(0); + + // Plain text input so Html2Text is called only for powered by text when building text part + $this->html2Text->expects($this->once()) + ->method('__invoke') + ->with('Powered') + ->willReturn('Powered'); + + $constructor = new SystemMailContentBuilder( + html2Text: $this->html2Text, + configProvider: $this->configProvider, + templateRepository: $this->templateRepository, + templateImageManager: $this->templateImageManager, + poweredByPhplist: false, + ); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = 'Body'; + + [$html, $text] = $constructor($dto); + + // HTML should contain processed powered-by image (src rewritten to powerphplist.png) in place of [SIGNATURE] + $this->assertStringContainsString('Subject: Body', $html); + $this->assertStringContainsString('src="powerphplist.png"', $html); + + // Text should include powered by text substituted into [SIGNATURE] + $this->assertStringContainsString("SUBJ: Subject\n[BODY]\nBody\nPowered", $text); + } + + public function testTemplateWithoutSignatureAppendsPoweredByTextAndBeforeBodyEndWhenHtml(): void + { + // Configure template usage with poweredByPhplist=true (use text snippet instead of image) + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PoweredByText, 'PB'], + [ConfigOption::SystemMessageTemplate, '11'], + ]); + + $template = new Template('sys-template'); + $template->setContent('[CONTENT]'); + $template->setText('[CONTENT]'); + $this->templateRepository->method('findOneById')->with(11)->willReturn($template); + + $this->templateImageManager->method('parseLogoPlaceholders')->willReturnCallback(static fn ($h) => $h); + + // Html2Text is called twice: once for the HTML message -> text, and once for powered-by text + $this->html2Text->expects($this->exactly(2)) + ->method('__invoke') + ->withConsecutive( + ['Hello World'], + ['PB'] + ) + ->willReturnOnConsecutiveCalls('Hello World', 'PB'); + + $constructor = new SystemMailContentBuilder( + html2Text: $this->html2Text, + configProvider: $this->configProvider, + templateRepository: $this->templateRepository, + templateImageManager: $this->templateImageManager, + poweredByPhplist: true, + ); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Sub'; + $dto->content = 'Hello World'; + + [$html, $text] = $constructor($dto); + + // HTML path: since poweredByPhplist=true, raw PoweredByText should be inserted before + $this->assertStringContainsString('Hello World', $html); + $this->assertMatchesRegularExpression('~PB\s*$~', $html); + + // TEXT path: PoweredByText (converted) appended with two newlines since no [SIGNATURE] + $this->assertSame("Hello World\n\nPB", $text); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageEmbedderTest.php b/tests/Unit/Domain/Messaging/Service/TemplateImageEmbedderTest.php new file mode 100644 index 00000000..9190467a --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/TemplateImageEmbedderTest.php @@ -0,0 +1,239 @@ +configProvider = $this->createMock(ConfigProvider::class); + $this->configManager = $this->createMock(ConfigManager::class); + $this->externalImageService = $this->createMock(ExternalImageService::class); + $this->templateImageRepository = $this->createMock(TemplateImageRepository::class); + + // Create a temporary document root for filesystem-related tests + $this->documentRoot = sys_get_temp_dir() . '/tpl_img_embedder_' . bin2hex(random_bytes(6)); + mkdir($this->documentRoot, 0777, true); + + // Reasonable defaults for options used in code + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + } + + protected function tearDown(): void + { + // best-effort cleanup + if (is_dir($this->documentRoot)) { + $this->recursiveRemove($this->documentRoot); + } + } + + private function recursiveRemove(string $path): void + { + if (!is_dir($path)) { + unlink($path); + return; + } + foreach (scandir($path) ?: [] as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $full = $path . DIRECTORY_SEPARATOR . $file; + if (is_dir($full)) { + $this->recursiveRemove($full); + } else { + unlink($full); + } + } + rmdir($path); + } + + private function createEmbedder( + bool $embedExternal = false, + bool $embedUploaded = false, + ?string $uploadImagesDir = null, + string $editorImagesDir = 'images' + ): TemplateImageEmbedder { + return new TemplateImageEmbedder( + configProvider: $this->configProvider, + configManager: $this->configManager, + externalImageService: $this->externalImageService, + templateImageRepository: $this->templateImageRepository, + documentRoot: $this->documentRoot, + editorImagesDir: $editorImagesDir, + embedExternalImages: $embedExternal, + embedUploadedImages: $embedUploaded, + uploadImagesDir: $uploadImagesDir, + ); + } + + public function testExternalImagesEmbeddedAndSameHostLeftAlone(): void + { + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + $html = '

and ' + . '

'; + + $this->externalImageService->expects($this->exactly(2)) + ->method('cache') + ->withConsecutive( + ['https://cdn.other.org/pic.jpg', 111], + ['https://example.com/local.jpg', 111] + ) + ->willReturnOnConsecutiveCalls(true, false); + + $jpegBase64 = base64_encode('JPEGDATA'); + $this->externalImageService->expects($this->once()) + ->method('getFromCache') + ->with('https://cdn.other.org/pic.jpg', 111) + ->willReturn($jpegBase64); + + $embedder = $this->createEmbedder(embedExternal: true); + $out = $embedder($html, 111); + + $this->assertStringContainsString('cid:', $out); + $this->assertStringContainsString('https://example.com/local.jpg', $out, 'Same-host URL should remain'); + $this->assertCount(1, $embedder->attachment); + $att = $embedder->attachment[0]; + $this->assertSame('base64', $att[3]); + $this->assertSame('image/jpeg', $att[4]); + } + + public function testTemplateImagesAreEmbeddedIncludingPoweredBySpecialCase(): void + { + // Template id used + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '42'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + $html = '
'; + + // For normal image, repository called with templateId 42 + $tplImg1 = $this->createMock(TemplateImage::class); + $tplImg1->method('getData')->willReturn(base64_encode('IMG1')); + + // For powerphplist.png, templateId should be 0 per implementation + $tplImg2 = $this->createMock(TemplateImage::class); + $tplImg2->method('getData')->willReturn(base64_encode('IMG2')); + + $this->templateImageRepository->method('findByTemplateIdAndFilename') + ->willReturnCallback(function (int $tplId, string $filename) use ($tplImg1, $tplImg2) { + if ($filename === '/assets/logo.jpg') { + // In current implementation, first pass checks templateId as provided + return $tplImg1; + } + if ($filename === 'powerphplist.png') { + return $tplImg2; + } + return null; + }); + + $embedder = $this->createEmbedder(); + $out = $embedder($html, 7); + + // Both images should be replaced with cid references + $this->assertSame(2, substr_count($out, 'cid:')); + $this->assertStringNotContainsString('/assets/logo.jpg', $out); + $this->assertStringNotContainsString('powerphplist.png"', $out, 'basename is replaced by cid'); + $this->assertCount(2, $embedder->attachment); + } + + public function testFilesystemUploadedImagesAreEmbeddedAndConfigIsUpdated(): void + { + // Prepare upload dir structure and file + $uploadDir = $this->documentRoot . '/uploads'; + mkdir($uploadDir . '/image', 0777, true); + $filePath = $uploadDir . '/image/pic.png'; + file_put_contents($filePath, 'PNGDATA'); + + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + // Expect configManager->create called when a path with non-null config is used + $this->configManager->expects($this->atLeastOnce()) + ->method('create'); + + $html = '

'; + + $embedder = $this->createEmbedder(embedUploaded: true, uploadImagesDir: 'uploads'); + $out = $embedder($html, 22); + + $this->assertStringContainsString('cid:', $out); + $this->assertCount(1, $embedder->attachment); + $att = $embedder->attachment[0]; + $this->assertSame('image/png', $att[4]); + } + + public function testNoOpWhenFlagsOffAndNoTemplateMatch(): void + { + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + // Neither external nor uploaded embedding enabled; repository returns null + $this->templateImageRepository->method('findByTemplateIdAndFilename')->willReturn(null); + + $html = ''; + $embedder = $this->createEmbedder(); + $out = $embedder($html, 1); + + $this->assertSame($html, $out); + $this->assertSame([], $embedder->attachment); + } + + public function testUnknownExtensionIsIgnored(): void + { + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, 0], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + $html = ''; + $embedder = $this->createEmbedder(embedExternal: true, embedUploaded: true); + $out = $embedder($html, 5); + + // .svg is not in allowed extensions → untouched, no attachments + $this->assertSame($html, $out); + $this->assertSame([], $embedder->attachment); + } +}