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..0a39fd1b 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "phplist/core", "description": "The core module of phpList, the world's most popular open source newsletter manager", - "type": "phplist-module", + "type": "symfony-bundle", "keywords": [ "phplist", "email", @@ -46,6 +46,7 @@ }, "require": { "php": "^8.1", + "symfony/framework-bundle": "^6.4", "symfony/dependency-injection": "^6.4", "symfony/config": "^6.4", "symfony/yaml": "^6.4", @@ -79,7 +80,13 @@ "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", + "phpdocumentor/reflection-docblock": "^5.2" }, "require-dev": { "phpunit/phpunit": "^9.5", @@ -92,7 +99,6 @@ "symfony/test-pack": "^1.1", "symfony/process": "^6.4", "composer/composer": "^2.7", - "symfony/framework-bundle": "^6.4", "symfony/http-kernel": "^6.4", "symfony/http-foundation": "^6.4", "symfony/routing": "^6.4", @@ -152,8 +158,8 @@ "Doctrine\\Bundle\\DoctrineBundle\\DoctrineBundle", "Doctrine\\Bundle\\MigrationsBundle\\DoctrineMigrationsBundle", "PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle", - "FOS\\RestBundle\\FOSRestBundle", - "TatevikGr\\RssFeedBundle\\RssFeedBundle" + "PhpList\\Core\\EmptyStartPageBundle\\PhpListCoreBundle", + "FOS\\RestBundle\\FOSRestBundle" ], "routes": { "homepage": { diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index b3b8a8d4..a2f21165 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -7,7 +7,7 @@ */Migrations/* - + @@ -33,7 +33,7 @@ - + @@ -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..628f1e45 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): 'example.com' # 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)%%' @@ -71,6 +81,8 @@ parameters: # A secret key that's used to generate certain security-related tokens secret: '%%env(PHPLIST_SECRET)%%' env(PHPLIST_SECRET): %1$s + phplist.verify_ssl: '%%env(VERIFY_SSL)%%' + env(VERIFY_SSL): '1' graylog_host: 'graylog.example.com' graylog_port: 12201 @@ -89,3 +101,57 @@ 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' + messaging.send_list_admin_copy: '%%env(SEND_LISTADMIN_COPY)%%' + env(SEND_LISTADMIN_COPY): '0' + + phplist.forward_email_period: '%%env(FORWARD_EMAIL_PERIOD)%%' + env(FORWARD_EMAIL_PERIOD): '1 minute' + phplist.forward_email_count: '%%env(FORWARD_EMAIL_COUNT)%%' + env(FORWARD_EMAIL_COUNT): '1' + phplist.forward_personal_note_size: '%%env(FORWARD_PERSONAL_NOTE_SIZE)%%' + env(FORWARD_PERSONAL_NOTE_SIZE): '0' + phplist.forward_friend_count_attribute: '%%env(FORWARD_FRIEND_COUNT_ATTRIBUTE)%%' + env(FORWARD_FRIEND_COUNT_ATTRIBUTE): '' + phplist.keep_forwarded_attributes: '%%env(KEEPFORWARDERATTRIBUTES)%%' + env(KEEPFORWARDERATTRIBUTES): '0' + + 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' + phplist.max_avatar_size: '%%env(MAX_AVATAR_SIZE)%%' + env(MAX_AVATAR_SIZE): '100000' diff --git a/config/services/builders.yml b/config/services/builders.yml index 10a994a4..c57ac009 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -4,22 +4,34 @@ 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 + PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder: + arguments: + $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\EmailBuilder: + arguments: + $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\MessageOptionsBuilder: - autowire: true - autoconfigure: true + PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder: + arguments: + $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..6ae953d4 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -5,36 +5,12 @@ 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' ] 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..93134c38 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,26 @@ services: PhpList\Core\Domain\Messaging\Service\EmailService: autowire: true autoconfigure: true - arguments: - $defaultFromEmail: '%app.mailer_from%' - $bounceEmail: '%imap_bounce.email%' + + PhpList\Core\Domain\Messaging\Service\MessageForwardService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardingGuard: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardContentService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardDeliveryService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardingStatsService: + autowire: true + autoconfigure: true PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: autowire: true @@ -43,6 +65,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,10 +195,22 @@ services: autoconfigure: true public: true - PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + PhpList\Core\Domain\Identity\Service\AdminNotifier: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Identity\Service\AdminCopyEmailSender: autowire: true autoconfigure: true + 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 autoconfigure: true @@ -133,3 +220,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..090b4f5d 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -738,6 +738,94 @@ 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% + + + 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 + + + Fwd + __Fwd + + + (test) + __(test) + + + Message Forwarded + __Message Forwarded + + + %subscriber% tried forwarding message %campaignId% to %email% but failed + __%subscriber% tried forwarding message %campaignId% to %email% but failed + + + %subscriber% has forwarded message %campaignId% to %email% + __%subscriber% has forwarded message %campaignId% to %email% + 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/Composer/ModuleFinder.php b/src/Composer/ModuleFinder.php index 110006e4..2e69c447 100644 --- a/src/Composer/ModuleFinder.php +++ b/src/Composer/ModuleFinder.php @@ -35,10 +35,14 @@ public function injectPackageRepository(PackageRepository $repository): void } /** - * Finds the bundles class in all installed modules. + * Finds the bundle classes declared by all installed packages (including the root package). * - * @return string[][] class names of the bundles of all installed phpList modules: - * ['module package name' => ['bundle class name 1', 'bundle class name 2']] + * We intentionally scan all packages, not only those with a specific type, because the root + * package or other dependencies can also declare bundles via the "extra.phplist/core.bundles" + * section in their composer.json. + * + * @return string[][] class names of the bundles grouped by package name: + * ['package name' => ['Bundle\Class\Name1', 'Bundle\Class\Name2']] * * @throws InvalidArgumentException */ @@ -47,7 +51,8 @@ public function findBundleClasses(): array /** @var string[][] $bundleSets */ $bundleSets = []; - $modules = $this->packageRepository->findModules(); + // Look at ALL packages (including the root), as they may declare bundles + $modules = $this->packageRepository->findAll(); foreach ($modules as $module) { $extra = $module->getExtra(); $this->validateBundlesSectionInExtra($extra); @@ -131,10 +136,14 @@ public function createBundleConfigurationYaml(): string } /** - * Finds the routes in all installed modules. + * Finds the routes declared by all installed packages (including the root package). * - * @return array[] class names of the routes of all installed phpList modules: - * ['route name' => [route configuration] + * We intentionally scan all packages, not only those with a specific type, because the root + * package or other dependencies can also declare routes via the "extra.phplist/core.routes" + * section in their composer.json. + * + * @return array[] routes keyed by prefixed route name: + * ['vendor/package.route_name' => [route configuration]] * * @throws InvalidArgumentException */ @@ -143,7 +152,8 @@ public function findRoutes(): array /** @var array[] $routes */ $routes = []; - $modules = $this->packageRepository->findModules(); + // Look at ALL packages (including the root), as they may declare routes + $modules = $this->packageRepository->findAll(); foreach ($modules as $module) { $extra = $module->getExtra(); $this->validateRoutesSectionInExtra($extra); diff --git a/src/Core/Version.php b/src/Core/Version.php new file mode 100644 index 00000000..0303b97f --- /dev/null +++ b/src/Core/Version.php @@ -0,0 +1,10 @@ +load($file); + } + } + } +} diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 902092f6..60230dd3 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,8 +38,9 @@ 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 { + // todo: in case of forwarded message, we need to use 'forwarded' instead of user id if (!$this->isExtractAndSaveLinksApplicable()) { return []; } @@ -49,10 +49,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..4af3eba6 --- /dev/null +++ b/src/Domain/Common/ExternalImageService.php @@ -0,0 +1,223 @@ +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, $this->verifySsl); + 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..b8995b35 --- /dev/null +++ b/src/Domain/Common/FileHelper.php @@ -0,0 +1,64 @@ +]*>(.*?)<\/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..2e67097f --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php @@ -0,0 +1,194 @@ +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 + { + $user = new Subscriber(); + $user->setEmail($email); + $user->setUniqueId($uid); + return $user; + } + + 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, + keepForwardedAttributes: false + ); + + $html = 'Hello'; + $processedHtml = $processor->process( + value: $html, + receiver: $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, + receiver: $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'); + $forwardedBy = $this->makeUser('bob@example.com', 'U-991'); + $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, + keepForwardedAttributes: false + ); + + $content = 'Hi [EMAIL], id=[USERID], web=[WEBSITE], dom=[DOMAIN], org=[ORGANIZATION_NAME].'; + $out = $processor->process( + value: $content, + receiver: $user, + format: OutputFormat::Text, + messagePrecacheDto: $dto, + campaignId: 101, + forwardedBy: $forwardedBy, + ); + + $this->assertStringContainsString('Hi alice@example.com,', $out); + $this->assertStringContainsString('id=forwarded,', $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, + keepForwardedAttributes: false + ); + + $content = 'A [CUSTOM] B [UPPER:abc] C [SUPPORT]'; + $out = $processor->process( + value: $content, + receiver: $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..1508cf3d --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php @@ -0,0 +1,125 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(string $email = 'user@example.com', string $uid = 'UID-1'): Subscriber + { + $u = new Subscriber(); + $u->setEmail($email); + $u->setUniqueId($uid); + return $u; + } + + private function makeDto( + string $textFooter = 'TEXT_FOOT', + string $htmlFooter = 'HTML_FOOT', + string $footer = '' + ): MessagePrecacheDto { + $dto = new MessagePrecacheDto(); + $dto->textFooter = $textFooter; + $dto->htmlFooter = $htmlFooter; + $dto->footer = $footer; + return $dto; + } + + public function testName(): void + { + $resolver = new FooterValueResolver($this->config, false); + $this->assertSame('FOOTER', $resolver->name()); + } + + public function testReturnsDtoFooterWhenNotForwardedText(): void + { + $resolver = new FooterValueResolver($this->config, false); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + messagePrecacheDto: $this->makeDto('TF', 'HF') + ); + + $this->assertSame('TF', $resolver($ctx)); + } + + public function testReturnsDtoFooterWhenNotForwardedHtml(): void + { + $resolver = new FooterValueResolver($this->config, false); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: $this->makeDto('TF', 'HF') + ); + + $this->assertSame('HF', $resolver($ctx)); + } + + public function testForwardedAlternativeUsesStripslashesFooter(): void + { + // footer contains escaped quotes/backslashes, should be unescaped by stripslashes + $raw = "It\\'s \\\"fine\\\" \\ path"; + $dto = $this->makeDto('TF', 'HF', $raw); + + $resolver = new FooterValueResolver($this->config, true); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + messagePrecacheDto: $dto, + forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + ); + $this->assertSame(stripslashes($raw), $resolver($ctx)); + } + + public function testForwardedUsesConfigForwardFooterWhenFlagFalse(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardFooter) + ->willReturn('Forward footer set by config'); + + $resolver = new FooterValueResolver($this->config, false); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: $this->makeDto('TF', 'HF', 'Alt'), + forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + ); + + $this->assertSame('Forward footer set by config', $resolver($ctx)); + } + + public function testForwardedFallsBackToEmptyWhenConfigNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardFooter) + ->willReturn(null); + + $resolver = new FooterValueResolver($this->config, false); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: $this->makeDto('TF', 'HF', 'Alt'), + forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + ); + + $this->assertSame('', $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..3ae672eb --- /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, 'forwarded') + ->willReturn($blacklistBase . '?uid=forwarded'); + + $this->translator + ->method('trans') + ->with('Unsubscribe') + ->willReturn('Unsubscribe'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UID-FWD'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: (new Subscriber())->setEmail('someone@example.com'), + ); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $out = $resolver($ctx); + + $this->assertStringContainsString( + 'href="' + . htmlspecialchars($blacklistBase . '?uid=forwarded', 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/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php index 12e36ed9..c5744908 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php @@ -119,13 +119,13 @@ public function testIsEnabledFallsBackToDefaultsWhenRepoMissing(): void $this->defaults ->expects($this->once()) ->method('has') - ->with($key->value) + ->with($key) ->willReturn(true); $this->defaults ->expects($this->once()) ->method('get') - ->with($key->value) + ->with($key) ->willReturn(['value' => '1']); $this->assertTrue($this->provider->isEnabled($key)); @@ -210,13 +210,13 @@ public function testGetValueFallsBackToDefaultConfigsWhenNoCacheAndNoRepo(): voi $this->defaults ->expects($this->once()) ->method('has') - ->with($key->value) + ->with($key) ->willReturn(true); $this->defaults ->expects($this->once()) ->method('get') - ->with($key->value) + ->with($key) ->willReturn(['value' => 'DEF']); $this->assertSame('DEF', $this->provider->getValue($key)); @@ -231,7 +231,7 @@ public function testGetValueReturnsNullWhenNoCacheNoRepoNoDefault(): void $this->repo->expects($this->once())->method('findValueByItem')->with($key->value)->willReturn(null); $this->cache->expects($this->once())->method('set')->with($cacheKey, null, 300); - $this->defaults->expects($this->once())->method('has')->with($key->value)->willReturn(false); + $this->defaults->expects($this->once())->method('has')->with($key)->willReturn(false); $this->defaults->expects($this->never())->method('get'); $this->assertNull($this->provider->getValue($key)); diff --git a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php index ae5b96cb..1d79369a 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service\Provider; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -26,12 +27,12 @@ protected function setUp(): void public function testHasReturnsTrueForKnownKey(): void { - $this->assertTrue($this->provider->has('admin_address')); + $this->assertTrue($this->provider->has(ConfigOption::AdminAddress)); } public function testGetReturnsArrayShapeForKnownKey(): void { - $item = $this->provider->get('admin_address'); + $item = $this->provider->get(ConfigOption::AdminAddress); $this->assertIsArray($item); $this->assertArrayHasKey('value', $item); @@ -45,15 +46,9 @@ public function testGetReturnsArrayShapeForKnownKey(): void $this->assertStringContainsString('[DOMAIN]', (string) $item['value']); } - public function testGetReturnsProvidedDefaultWhenUnknownKey(): void - { - $fallback = ['value' => 'X', 'type' => 'text']; - $this->assertSame($fallback, $this->provider->get('does_not_exist', $fallback)); - } - public function testRemoteProcessingSecretIsRandomHexOfExpectedLength(): void { - $item = $this->provider->get('remote_processing_secret'); + $item = $this->provider->get(ConfigOption::RemoteProcessingSecret); $this->assertIsArray($item); $this->assertArrayHasKey('value', $item); @@ -64,7 +59,7 @@ public function testRemoteProcessingSecretIsRandomHexOfExpectedLength(): void public function testSubscribeUrlDefaultsToHttpAndApiV2Path(): void { - $item = $this->provider->get('subscribeurl'); + $item = $this->provider->get(ConfigOption::SubscribeUrl); $this->assertIsArray($item); $url = (string) $item['value']; @@ -75,7 +70,7 @@ public function testSubscribeUrlDefaultsToHttpAndApiV2Path(): void public function testUnsubscribeUrlDefaults(): void { - $item = $this->provider->get('unsubscribeurl'); + $item = $this->provider->get(ConfigOption::UnsubscribeUrl); $url = (string) $item['value']; $this->assertStringStartsWith('http://', $url); @@ -88,7 +83,7 @@ public function testTranslatorIsUsedOnlyOnFirstInit(): void ->expects($this->atLeastOnce()) ->method('trans') ->willReturnArgument(0); - $this->provider->get('admin_address'); + $this->provider->get(ConfigOption::AdminAddress); // Subsequent calls should not trigger init again $translator = $this->createMock(TranslatorInterface::class); @@ -100,8 +95,8 @@ public function testTranslatorIsUsedOnlyOnFirstInit(): void $prop = $reflection->getProperty('translator'); $prop->setValue($this->provider, $translator); - $this->provider->get('unsubscribeurl'); - $this->provider->has('pageheader'); + $this->provider->get(ConfigOption::UnsubscribeUrl); + $this->provider->has(ConfigOption::BlacklistUrl); } public function testKnownKeysHaveReasonableTypes(): void @@ -110,8 +105,6 @@ public function testKnownKeysHaveReasonableTypes(): void 'admin_address' => 'email', 'organisation_name' => 'text', 'organisation_logo' => 'image', - 'date_format' => 'text', - 'rc_notification' => 'boolean', 'notify_admin_login' => 'boolean', 'message_from_address' => 'email', 'message_from_name' => 'text', @@ -119,7 +112,7 @@ public function testKnownKeysHaveReasonableTypes(): void ]; foreach ($keys as $key => $type) { - $item = $this->provider->get($key); + $item = $this->provider->get(ConfigOption::from($key)); $this->assertIsArray($item, 'Item should be an array. Key: ' . $key); $this->assertSame($type, $item['type'] ?? null, $key .': should have type ' . $type); } 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/AdminCopyEmailSenderTest.php b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php new file mode 100644 index 00000000..00760fa7 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php @@ -0,0 +1,177 @@ +createMock(ConfigProvider::class); + $configProvider->expects(self::once()) + ->method('isEnabled') + ->with(ConfigOption::SendAdminCopies) + ->willReturn(false); + + $systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); + $systemEmailBuilder->expects(self::never())->method('buildSystemEmail'); + + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects(self::never())->method('send'); + + $sender = new AdminCopyEmailSender( + configProvider: $configProvider, + systemEmailBuilder: $systemEmailBuilder, + mailer: $mailer, + logger: $this->createMock(LoggerInterface::class), + sendListAdminCopy: true, + bounceEmail: 'bounce@example.com', + ); + + $sender->__invoke('Subject', 'Message body'); + } + + public function testSendsToListOwnersWhenFlagEnabled(): void + { + $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('isEnabled') + ->with(ConfigOption::SendAdminCopies) + ->willReturn(true); + + $emails = ['owner1@example.com', 'owner2@example.com']; + + $systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); + // Expect called exactly for unique owner emails + $systemEmailBuilder->expects(self::exactly(count($emails))) + ->method('buildSystemEmail') + ->with(self::callback(function (MessagePrecacheDto $data): bool { + return $data->to !== null + && str_starts_with($data->subject, 'phpList ') + && $data->content === 'Hello Admin'; + })) + ->willReturn(new Email()); + + $mailer = $this->createMock(MailerInterface::class); + + $bounce = 'bounces@phplist.test'; + $invocationIndex = 0; + $mailer->expects(self::exactly(count($emails))) + ->method('send') + ->with( + self::isInstanceOf(Email::class), + self::callback(function (Envelope $envelope) use ($emails, &$invocationIndex, $bounce): bool { + // Verify bounce/sender address + $sender = $envelope->getSender(); + $recipient = $envelope->getRecipients()[0] ?? null; + $expectedRecipient = $emails[$invocationIndex++] ?? null; + + return $sender !== null + && $sender->getAddress() === $bounce + && $recipient !== null + && $recipient->getAddress() === $expectedRecipient; + }) + ); + + // Build lists with owners, including duplicates and a null owner + $list1 = $this->createListWithOwner('owner1@example.com'); + $list2 = $this->createListWithOwner('owner2@example.com'); + // no owner + $list3 = new SubscriberList(); + // duplicate owner to test de-dup + $list4 = $this->createListWithOwner('owner1@example.com'); + + $sender = new AdminCopyEmailSender( + configProvider: $configProvider, + systemEmailBuilder: $systemEmailBuilder, + mailer: $mailer, + logger: $this->createMock(LoggerInterface::class), + sendListAdminCopy: true, + bounceEmail: $bounce, + ); + + $sender->__invoke('Test Subject', 'Hello Admin', [$list1, $list2, $list3, $list4]); + } + + public function testFallsBackToAdminAddressesWhenNoOwnersOrFlagFalse(): void + { + $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('isEnabled') + ->with(ConfigOption::SendAdminCopies) + ->willReturn(true); + + $configProvider->expects(self::exactly(2)) + ->method('getValue') + ->withConsecutive([ConfigOption::AdminAddress], [ConfigOption::AdminAddresses]) + ->willReturnOnConsecutiveCalls( + 'single@example.com', + ' admin1@example.com, , admin2@example.com ,admin1@example.com ' + ); + + $expectedRecipients = ['admin1@example.com', 'admin2@example.com', 'single@example.com']; + + $systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); + $systemEmailBuilder->expects(self::exactly(count($expectedRecipients))) + ->method('buildSystemEmail') + ->with(self::callback(function (MessagePrecacheDto $data): bool { + return $data->to !== null && str_starts_with($data->subject, 'phpList '); + })) + ->willReturn(new Email()); + + $mailer = $this->createMock(MailerInterface::class); + $bounce = 'bounce@domain.test'; + $i = 0; + $mailer->expects(self::exactly(count($expectedRecipients))) + ->method('send') + ->with( + self::isInstanceOf(Email::class), + self::callback(function (Envelope $envelope) use ($expectedRecipients, &$i, $bounce): bool { + $sender = $envelope->getSender(); + $recipient = $envelope->getRecipients()[0] ?? null; + $expected = $expectedRecipients[$i++] ?? null; + return $sender !== null + && $sender->getAddress() === $bounce + && $recipient !== null + && $recipient->getAddress() === $expected; + }) + ); + + $sender = new AdminCopyEmailSender( + configProvider: $configProvider, + systemEmailBuilder: $systemEmailBuilder, + mailer: $mailer, + logger: $this->createMock(LoggerInterface::class), + // ensure fallback path regardless of list owners + sendListAdminCopy: false, + bounceEmail: $bounce, + ); + + // Even if lists have owners, flag=false should ignore them and use AdminAddress(es) + $listWithOwner = $this->createListWithOwner('ignored@example.com'); + $sender->__invoke('System Update', 'Body', [$listWithOwner]); + } + + private function createListWithOwner(string $email): SubscriberList + { + $admin = new Administrator(); + $admin->setEmail($email); + + $list = new SubscriberList(); + $list->setOwner($admin); + + return $list; + } +} diff --git a/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php new file mode 100644 index 00000000..74d6bdd0 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php @@ -0,0 +1,167 @@ +adminCopyEmailSender = $this->createMock(AdminCopyEmailSender::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + } + + public function testNotifyForwardFailedSendsAdminCopyAndLogs(): void + { + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(42); + + $subscriber = new Subscriber(); + $subscriber->setEmail('john@example.com'); + + $friendEmail = 'friend@example.com'; + $lists = [new SubscriberList()]; + + $expectedSubject = 'Message Forwarded'; + $expectedMessage = sprintf( + '%s tried forwarding message %d to %s but failed', + $subscriber->getEmail(), + 42, + $friendEmail + ); + + // Translator expectations: first for subject, then for message with placeholders + $this->translator + ->expects(self::exactly(2)) + ->method('trans') + ->withConsecutive( + [$this->equalTo('Message Forwarded')], + [ + $this->equalTo('%subscriber% tried forwarding message %campaignId% to %email% but failed'), + $this->callback(function (array $params) use ($subscriber, $friendEmail): bool { + return ($params['%subscriber%'] ?? null) === $subscriber->getEmail() + && ($params['%campaignId%'] ?? null) === 42 + && ($params['%email%'] ?? null) === $friendEmail; + }) + ] + ) + ->willReturnOnConsecutiveCalls( + $expectedSubject, + $expectedMessage + ); + + // Admin copy sender should be invoked with translated subject and message and same lists + $this->adminCopyEmailSender + ->expects(self::once()) + ->method('__invoke') + ->with( + $this->equalTo($expectedSubject), + $this->equalTo($expectedMessage), + $this->identicalTo($lists) + ); + + // EventLogManager should log only on failure + $this->eventLogManager + ->expects(self::once()) + ->method('log') + ->with( + $this->equalTo('forward'), + $this->equalTo('Error loading message 42 in cache') + ); + + $notifier = new AdminNotifier( + adminCopyEmailSender: $this->adminCopyEmailSender, + translator: $this->translator, + eventLogManager: $this->eventLogManager, + ); + + $notifier->notifyForwardFailed( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: $friendEmail, + lists: $lists + ); + } + + public function testNotifyForwardSucceededSendsAdminCopyWithoutLogging(): void + { + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(777); + + $subscriber = new Subscriber(); + $subscriber->setEmail('alice@example.com'); + + $friendEmail = 'bob@example.net'; + $lists = [new SubscriberList(), new SubscriberList()]; + + $expectedSubject = 'Message Forwarded'; + $expectedMessage = sprintf( + '%s has forwarded message %d to %s', + $subscriber->getEmail(), + 777, + $friendEmail + ); + + $this->translator + ->expects(self::exactly(2)) + ->method('trans') + ->withConsecutive( + [$this->equalTo('Message Forwarded')], + [ + $this->equalTo('%subscriber% has forwarded message %campaignId% to %email%'), + $this->callback(function (array $params) use ($subscriber, $friendEmail): bool { + return ($params['%subscriber%'] ?? null) === $subscriber->getEmail() + && ($params['%campaignId%'] ?? null) === 777 + && ($params['%email%'] ?? null) === $friendEmail; + }) + ] + ) + ->willReturnOnConsecutiveCalls( + $expectedSubject, + $expectedMessage + ); + + $this->adminCopyEmailSender + ->expects(self::once()) + ->method('__invoke') + ->with( + $this->equalTo($expectedSubject), + $this->equalTo($expectedMessage), + $this->identicalTo($lists) + ); + + $this->eventLogManager + ->expects(self::never()) + ->method('log'); + + $notifier = new AdminNotifier( + adminCopyEmailSender: $this->adminCopyEmailSender, + translator: $this->translator, + eventLogManager: $this->eventLogManager, + ); + + $notifier->notifyForwardSucceeded( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: $friendEmail, + lists: $lists + ); + } +} 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..0f943cdb 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('buildCampaignEmail') + ->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('buildCampaignEmail') + ->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('buildCampaignEmail') + ->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/Model/MessageTest.php b/tests/Unit/Domain/Messaging/Model/MessageTest.php index 0201f08b..8abf952b 100644 --- a/tests/Unit/Domain/Messaging/Model/MessageTest.php +++ b/tests/Unit/Domain/Messaging/Model/MessageTest.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; @@ -29,7 +30,7 @@ class MessageTest extends TestCase protected function setUp(): void { - $this->format = new MessageFormat(true, MessageFormat::FORMAT_TEXT); + $this->format = new MessageFormat(true, OutputFormat::Text->value); $this->schedule = new MessageSchedule(1, new DateTime(), 2, new DateTime(), null); $this->metadata = new MessageMetadata(); $this->content = new MessageContent('This is the body'); 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..fb4b8740 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -0,0 +1,418 @@ +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->createMock(CampaignMailContentBuilder::class); + $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, + mailContentBuilder: $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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail( + 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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail(messageId: 60, data: $dto, htmlPref: true); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php new file mode 100644 index 00000000..ddb0de83 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php @@ -0,0 +1,226 @@ +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->createMock(CampaignMailContentBuilder::class); + $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->httpReceivedStampBuilder = $this->createMock(HttpReceivedStampBuilder::class); + + // Defaults for config values used in headers + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PreferencesUrl, 'https://example.com/prefs'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsub'], + [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'], + [ConfigOption::AdminAddress, 'admin@example.com'], + [ConfigOption::AlwaysSendTextDomains, ''], + ]); + + $this->urlBuilder->method('withUid')->willReturnCallback( + static fn(string $url, ?string $uid): string => $url . (str_contains($url, '?') ? '&' : '?') . 'uid=' . $uid + ); + } + + private function makeBuilder( + string $googleSenderId = 'g-123', + bool $useAmazonSes = false, + bool $usePrecedenceHeader = true, + bool $devVersion = true, + ?string $devEmail = 'dev@example.com', + ): ForwardEmailBuilder { + return new ForwardEmailBuilder( + configProvider: $this->configProvider, + eventLogManager: $this->eventLogManager, + blacklistRepository: $this->blacklistRepository, + subscriberHistoryManager: $this->subscriberHistoryManager, + subscriberRepository: $this->subscriberRepository, + logger: $this->logger, + mailContentBuilder: $this->mailConstructor, + templateImageEmbedder: $this->templateImageEmbedder, + urlBuilder: $this->urlBuilder, + pdfGenerator: $this->pdfGenerator, + attachmentAdder: $this->attachmentAdder, + translator: $this->translator, + httpReceivedStampBuilder: $this->httpReceivedStampBuilder, + googleSenderId: $googleSenderId, + useAmazonSes: $useAmazonSes, + usePrecedenceHeader: $usePrecedenceHeader, + devVersion: $devVersion, + devEmail: $devEmail, + ); + } + + public function testBuildsForwardEmailWithSubjectPrefixHeadersAndReplyTo(): void + { + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false); + + $dto = new MessagePrecacheDto(); + // will be stripped of backslashes by stripslashes + $dto->subject = 'Hello \\"World\\"'; + $dto->content = 'Body text'; + $dto->sendFormat = null; + + $friendEmail = 'friend@example.com'; + $fromEmail = 'from@example.com'; + $fromName = 'From Name'; + + $this->translator->method('trans')->with('Fwd')->willReturn('Fwd'); + + $this->mailConstructor + ->expects(self::once()) + ->method('__invoke') + ->willReturn(['

HTML

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

HTML

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

HTML

'); + + $this->attachmentAdder + ->expects(self::once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 99, OutputFormat::Html, true) + ->willReturn(true); + + $this->httpReceivedStampBuilder + ->method('buildStamp') + ->willReturn('from host [127.0.0.1] by example.org with HTTP; Wed, 01 Jan 2025 00:00:00 +0000'); + + $builder = $this->makeBuilder(devVersion: true, devEmail: 'dev@example.com'); + [$email, $sentAs] = $builder->buildForwardEmail( + messageId: 99, + friendEmail: $friendEmail, + forwardedBy: new Subscriber(), + data: $dto, + htmlPref: true, + fromName: $fromName, + fromEmail: $fromEmail, + forwardedPersonalNote: 'See this', + ); + + $this->assertSame(OutputFormat::TextAndHtml, $sentAs); + + // Subject prefixed and stripslashes applied + $this->assertSame('Fwd: Hello "World"', $email->getSubject()); + + // Reply-To set + $this->assertSame($fromEmail, $email->getReplyTo()[0]->getAddress()); + $this->assertSame($fromName, $email->getReplyTo()[0]->getName()); + + // Received header present + $this->assertNotNull($email->getHeaders()->get('Received')); + + // Dev mode reroutes recipient + $this->assertSame('dev@example.com', $email->getTo()[0]->getAddress()); + } + + public function testReturnsNullWhenEmptySubjectAndLogs(): void + { + $dto = new MessagePrecacheDto(); + $dto->subject = ''; + $friend = 'friend@example.com'; + + $this->eventLogManager->expects(self::once())->method('log'); + + $this->expectException(InvalidRecipientOrSubjectException::class); + $this->expectExceptionMessage('Invalid recipient or subject.'); + + $builder = $this->makeBuilder(); + $builder->buildForwardEmail( + messageId: 1, + friendEmail: $friend, + forwardedBy: new Subscriber(), + data: $dto, + htmlPref: false, + fromName: 'X', + fromEmail: 'x@example.com', + ); + } + + public function testBlacklistReturnsNullAndMarksHistory(): void + { + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(true); + + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['setBlacklisted']) + ->getMock(); + $subscriber->expects(self::once())->method('setBlacklisted')->with(true); + + $this->subscriberRepository->method('findOneByEmail')->with('friend@example.com')->willReturn($subscriber); + $this->subscriberHistoryManager->expects(self::once())->method('addHistory'); + + $dto = new MessagePrecacheDto(); + $dto->subject = 'S'; + $this->expectException(EmailBlacklistedException::class); + $this->expectExceptionMessage('Email address is blacklisted.'); + + $builder = $this->makeBuilder(); + $result = $builder->buildForwardEmail( + messageId: 2, + friendEmail: 'friend@example.com', + forwardedBy: new Subscriber(), + data: $dto, + htmlPref: false, + fromName: 'From', + fromEmail: 'from@example.com', + ); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php new file mode 100644 index 00000000..aeb0d73c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php @@ -0,0 +1,75 @@ +buildStamp()); + } + + public function testReturnsNullWhenNoClientIp(): void + { + $stack = new RequestStack(); + $request = new Request(); + // Do not set REMOTE_ADDR to simulate missing client IP + $stack->push($request); + + $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + + self::assertNull($builder->buildStamp()); + } + + public function testBuildsStampWithRemoteHostAndFixedTime(): void + { + $stack = new RequestStack(); + $request = new Request(); + // Set client IP and remote host explicitly + $request->server->set('REMOTE_ADDR', '203.0.113.5'); + $request->server->set('REMOTE_HOST', 'client.example.org'); + // Fix the request time for deterministic output (Unix epoch start) + $request->server->set('REQUEST_TIME', 0); + $stack->push($request); + + $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + + $stamp = $builder->buildStamp(); + + self::assertSame( + 'from client.example.org [203.0.113.5] by api.example.test with HTTP; Thu, 01 Jan 1970 00:00:00 +0000', + $stamp + ); + } + + public function testBuildsStampWithIpOnlyNoReverseDns(): void + { + $stack = new RequestStack(); + $request = new Request(); + // Use a TEST-NET IP which should not resolve via gethostbyaddr + $request->server->set('REMOTE_ADDR', '203.0.113.55'); + // Ensure no REMOTE_HOST so builder attempts reverse DNS, which should fail and fallback to IP only + $request->server->remove('REMOTE_HOST'); + $request->server->set('REQUEST_TIME', 0); + $stack->push($request); + + $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + + $stamp = $builder->buildStamp(); + + self::assertSame( + 'from [203.0.113.55] by api.example.test with HTTP; Thu, 01 Jan 1970 00:00:00 +0000', + $stamp + ); + } +} 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..849449cb --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php @@ -0,0 +1,201 @@ +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->createMock(SystemMailContentBuilder::class); + $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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail(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->buildCampaignEmail( + messageId: 777, + data: $dto, + skipBlacklistCheck: false, + ); + + $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->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/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php new file mode 100644 index 00000000..1fa1b037 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -0,0 +1,134 @@ +cache = $this->createMock(CacheInterface::class); + $this->preparator = $this->createMock(MessageProcessingPreparator::class); + $this->builder = $this->createMock(ForwardEmailBuilder::class); + } + + public function testThrowsWhenCacheMissing(): void + { + $service = new ForwardContentService( + cache: $this->cache, + messagePreparator: $this->preparator, + forwardEmailBuilder: $this->builder, + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(10); + $subscriber = new Subscriber(); + + $this->cache + ->expects(self::once()) + ->method('get') + ->with('messaging.message.base.10.1') + ->willReturn(null); + + $this->expectException(MessageCacheMissingException::class); + + $service->getContents( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: 'friend@example.com', + forwardDto: new MessageForwardDto( + [], + 'uuid', + 'from@example.com', + 'From', + null + ) + ); + } + + public function testProcessesLinksAndDelegatesToBuilder(): void + { + $service = new ForwardContentService( + cache: $this->cache, + messagePreparator: $this->preparator, + forwardEmailBuilder: $this->builder, + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(42); + $subscriber = new Subscriber(); + $subscriber->setHtmlEmail(true); + + $cached = new MessagePrecacheDto(); + $processed = new MessagePrecacheDto(); + + $this->cache + ->expects(self::once()) + ->method('get') + ->with('messaging.message.base.42.1') + ->willReturn($cached); + + $this->preparator + ->expects(self::once()) + ->method('processMessageLinks') + ->with( + campaignId: 42, + cachedMessageDto: $cached, + subscriber: $subscriber + ) + ->willReturn($processed); + + $expectedEmail = new Email(); + $this->builder + ->expects(self::once()) + ->method('buildForwardEmail') + ->with( + messageId: 42, + friendEmail: 'f@example.com', + forwardedBy: $subscriber, + data: $processed, + htmlPref: true, + fromName: 'From Name', + fromEmail: 'from@example.com', + forwardedPersonalNote: 'note' + ) + ->willReturn([$expectedEmail, OutputFormat::Text]); + + $result = $service->getContents( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: 'f@example.com', + forwardDto: new MessageForwardDto( + ['f@example.com'], + 'uuid', + 'From Name', + 'from@example.com', + 'note' + ) + ); + + self::assertIsArray($result); + self::assertSame($expectedEmail, $result[0]); + self::assertSame(OutputFormat::Text, $result[1]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php new file mode 100644 index 00000000..5bbdfc0b --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php @@ -0,0 +1,114 @@ +mailer = $this->createMock(MailerInterface::class); + $this->forwardManager = $this->createMock(UserMessageForwardManager::class); + } + + public function testSendUsesBounceEnvelopeAndRecipient(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + + $email = (new Email())->to('friend@example.test'); + + $this->mailer->expects(self::once()) + ->method('send') + ->with( + self::identicalTo($email), + self::callback(function (Envelope $envelope): bool { + // Check that sender is the bounce address and recipient matches TO + return $envelope->getSender()->getAddress() === 'bounce@example.test' + && $envelope->getRecipients()[0]->getAddress() === 'friend@example.test'; + }) + ); + + $service->send($email); + } + + public function testSendThrowsWhenNoRecipient(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + // no recipients + $email = new Email(); + + $this->expectException(LogicException::class); + $service->send($email); + } + + public function testMarkSentDelegatesToManager(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + $friendEmail = 'friend@example.test'; + + $this->forwardManager->expects(self::once()) + ->method('create') + ->with( + subscriber: self::identicalTo($subscriber), + campaign: self::identicalTo($campaign), + friendEmail: $friendEmail, + status: 'sent' + ); + + $service->markSent($campaign, $subscriber, $friendEmail); + } + + public function testMarkFailedDelegatesToManager(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + $friendEmail = 'friend@example.test'; + + $this->forwardManager->expects(self::once()) + ->method('create') + ->with( + subscriber: self::identicalTo($subscriber), + campaign: self::identicalTo($campaign), + friendEmail: $friendEmail, + status: 'failed' + ); + + $service->markFailed($campaign, $subscriber, $friendEmail); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php new file mode 100644 index 00000000..8ac266f8 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php @@ -0,0 +1,146 @@ +subscriberRepo = $this->createMock(SubscriberRepository::class); + $this->userMessageRepo = $this->createMock(UserMessageRepository::class); + $this->forwardRepo = $this->createMock(UserMessageForwardRepository::class); + } + + public function testAssertCanForwardReturnsSubscriber(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + forwardEmailPeriod: '1 day', + ); + + $uid = 'abc'; + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->subscriberRepo->method('findOneByUniqueId')->with($uid)->willReturn($subscriber); + $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn( + $this->createMock(UserMessage::class) + ); + $this->forwardRepo->method('getCountByUserSince')->willReturn(1); + + $result = $guard->assertCanForward($uid, $campaign); + self::assertSame($subscriber, $result); + } + + public function testAssertCanForwardThrowsWhenSubscriberMissing(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + forwardEmailPeriod: '1 day', + ); + + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(null); + + $this->expectException(MessageNotReceivedException::class); + $guard->assertCanForward('uid', $this->createMock(Message::class)); + } + + public function testAssertCanForwardThrowsWhenMessageNotReceived(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + forwardEmailPeriod: '1 day', + ); + + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); + $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn(null); + + $this->expectException(MessageNotReceivedException::class); + $guard->assertCanForward('uid', $this->createMock(Message::class)); + } + + public function testAssertCanForwardThrowsWhenLimitExceeded(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + forwardEmailPeriod: '1 day', + ); + + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); + $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn($this->createMock(UserMessage::class)); + $this->forwardRepo->method('getCountByUserSince')->willReturn(2); + + $this->expectException(ForwardLimitExceededException::class); + $guard->assertCanForward('uid', $this->createMock(Message::class)); + } + + public function testHasAlreadyBeenSentTrue(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 10, + forwardEmailPeriod: '1 day', + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(7); + + $forward = (new UserMessageForward())->setStatus('sent'); + + $this->forwardRepo->method('findByEmailAndMessage')->with('friend@x.tld', 7)->willReturn($forward); + + self::assertTrue($guard->hasAlreadyBeenSent('friend@x.tld', $campaign)); + } + + public function testHasAlreadyBeenSentFalseWhenNone(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 10, + forwardEmailPeriod: '1 day', + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(8); + + $this->forwardRepo->method('findByEmailAndMessage')->willReturn(null); + + self::assertFalse($guard->hasAlreadyBeenSent('f@x.tld', $campaign)); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php new file mode 100644 index 00000000..cc8f34bc --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php @@ -0,0 +1,119 @@ +valueRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $this->attrManager = $this->createMock(SubscriberAttributeManager::class); + } + + public function testNoAttributeConfiguredDoesNothing(): void + { + $service = new ForwardingStatsService( + subscriberAttributeValueRepo: $this->valueRepo, + subscriberAttributeManager: $this->attrManager, + // becomes null internally + forwardFriendCountAttr: '' + ); + + $subscriber = $this->createMock(Subscriber::class); + + // No repository or manager calls expected + $this->valueRepo->expects(self::never())->method(self::anything()); + $this->attrManager->expects(self::never())->method(self::anything()); + + $service->incrementFriendsCount($subscriber); + $service->updateFriendsCount($subscriber); + // reached without interactions + self::assertTrue(true); + } + + public function testIncrementThenUpdatePersistsAndResets(): void + { + $service = new ForwardingStatsService( + subscriberAttributeValueRepo: $this->valueRepo, + subscriberAttributeManager: $this->attrManager, + forwardFriendCountAttr: 'FriendsForwarded' + ); + + $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 123]); + + // Simulate existing attribute value of 3 + $existing = $this->getMockBuilder(SubscriberAttributeValue::class) + ->disableOriginalConstructor() + ->onlyMethods(['getValue']) + ->getMock(); + $existing->method('getValue')->willReturn('3'); + + $this->valueRepo->expects(self::once()) + ->method('findOneBySubscriberAndAttributeName') + ->with(subscriber: self::identicalTo($subscriber), attributeName: 'FriendsForwarded') + ->willReturn($existing); + + // After two increments (3 -> 4 -> 5), update should persist '5' + $this->attrManager->expects(self::once()) + ->method('createOrUpdateByName') + ->with( + subscriber: self::identicalTo($subscriber), + attributeName: 'FriendsForwarded', + value: '5' + ); + + $service->incrementFriendsCount($subscriber); + $service->incrementFriendsCount($subscriber); + $service->updateFriendsCount($subscriber); + + // Second update attempt should be a no-op due to cache reset + $this->attrManager->expects(self::never())->method('createOrUpdateByName'); + $service->updateFriendsCount($subscriber); + self::assertTrue(true); + } + + public function testCacheIsolationBySubscriber(): void + { + $service = new ForwardingStatsService( + subscriberAttributeValueRepo: $this->valueRepo, + subscriberAttributeManager: $this->attrManager, + forwardFriendCountAttr: 'FriendsForwarded' + ); + + $subscriberA = $this->createConfiguredMock(Subscriber::class, ['getId' => 1]); + $subscriberB = $this->createConfiguredMock(Subscriber::class, ['getId' => 2]); + + // Initial load for A returns 0 + $this->valueRepo->expects(self::once()) + ->method('findOneBySubscriberAndAttributeName') + ->with(subscriber: self::identicalTo($subscriberA), attributeName: 'FriendsForwarded') + ->willReturn(null); + // cache for A becomes 1 + $service->incrementFriendsCount($subscriberA); + + // Expect exactly one persistence call overall (for A only) + $this->attrManager->expects(self::once()) + ->method('createOrUpdateByName') + ->with( + subscriber: self::identicalTo($subscriberA), + attributeName: 'FriendsForwarded', + value: '1' + ); + // Calling update for B must be a no-op (cache belongs to A) + $service->updateFriendsCount($subscriberB); + $service->updateFriendsCount($subscriberA); + } +} 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/Manager/UserMessageForwardManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php new file mode 100644 index 00000000..edf754c6 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php @@ -0,0 +1,69 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->manager = new UserMessageForwardManager($this->entityManager); + } + + public function testCreatePersistsAndReturnsForwardWithExpectedFields(): void + { + $subscriber = $this->createMock(Subscriber::class); + $message = $this->createMock(Message::class); + + $subscriber->method('getId')->willReturn(42); + $message->method('getId')->willReturn(7); + + $expectedFriendEmail = 'friend@example.test'; + + $persisted = null; + + $this->entityManager->expects($this->once()) + ->method('persist') + ->with( + $this->callback(function (UserMessageForward $fwd) use (&$persisted, $expectedFriendEmail) { + $persisted = $fwd; + return $fwd->getUserId() === 42 + && $fwd->getMessageId() === 7 + && $fwd->getForward() === $expectedFriendEmail + && $fwd->getStatus() === $this->expectedStatus + && $fwd->getCreatedAt() !== null; + }) + ); + + $this->entityManager->expects($this->never()) + ->method('flush'); + + $result = $this->manager->create( + subscriber: $subscriber, + campaign: $message, + friendEmail: $expectedFriendEmail, + status: $this->expectedStatus + ); + + $this->assertInstanceOf(UserMessageForward::class, $result); + $this->assertSame($persisted, $result, 'Returned entity should be the same instance that was persisted'); + $this->assertSame(42, $result->getUserId()); + $this->assertSame(7, $result->getMessageId()); + $this->assertSame($expectedFriendEmail, $result->getForward()); + $this->assertSame($this->expectedStatus, $result->getStatus()); + } +} 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/MessageForwardServiceTest.php b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php new file mode 100644 index 00000000..6e7c059a --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php @@ -0,0 +1,332 @@ +guard = $this->createMock(ForwardingGuard::class); + $this->delivery = $this->createMock(ForwardDeliveryService::class); + $this->loader = $this->createMock(MessageDataLoader::class); + $this->listRepo = $this->createMock(SubscriberListRepository::class); + $this->contentService = $this->createMock(ForwardContentService::class); + $this->precache = $this->createMock(MessagePrecacheService::class); + $this->notifier = $this->createMock(AdminNotifier::class); + $this->stats = $this->createMock(ForwardingStatsService::class); + } + + private function createService(): MessageForwardService + { + return new MessageForwardService( + guard: $this->guard, + forwardDeliveryService: $this->delivery, + messageDataLoader: $this->loader, + subscriberListRepository: $this->listRepo, + forwardContentService: $this->contentService, + precacheService: $this->precache, + adminNotifier: $this->notifier, + forwardingStatsService: $this->stats, + ); + } + + private function createDto(array $emails): MessageForwardDto + { + return new MessageForwardDto( + emails: $emails, + uid: 'uid-123', + fromName: 'Alice', + fromEmail: 'alice@example.test' + ); + } + + public function testSkipsAlreadySentAndStillUpdatesStats(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo($campaign)) + ->willReturn(['loaded' => true]); + + $this->guard->expects(self::once()) + ->method('assertCanForward') + ->willReturn($subscriber); + + $this->listRepo->expects(self::once()) + ->method('getListsByMessage') + ->with(self::identicalTo($campaign)) + ->willReturn([]); + + $this->guard->expects(self::exactly(2)) + ->method('hasAlreadyBeenSent') + ->willReturn(true); + + $this->precache->expects(self::never())->method('precacheMessage'); + $this->contentService->expects(self::never())->method('getContents'); + $this->delivery->expects(self::never())->method('send'); + $this->delivery->expects(self::never())->method('markSent'); + $this->delivery->expects(self::never())->method('markFailed'); + $this->notifier->expects(self::never())->method('notifyForwardSucceeded'); + $this->notifier->expects(self::never())->method('notifyForwardFailed'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $service->forward($this->createDto(['a@x.tld', 'b@x.tld']), $campaign); + } + + public function testPrecacheFailureNotifiesAndMarksFailed(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['L1']); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + + $this->precache->expects(self::once()) + ->method('precacheMessage') + ->with(self::identicalTo($campaign), ['ok' => true], true) + ->willReturn(false); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->contentService->expects(self::never())->method('getContents'); + $this->delivery->expects(self::never())->method('send'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $service->forward($this->createDto(['friend@example.test']), $campaign); + } + + public function testSuccessfulFlowSendsAndUpdatesEverything(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn([]); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + $this->precache->method('precacheMessage')->willReturn(true); + + $email1 = (new Email())->to('x1@example.test'); + $email2 = (new Email())->to('x2@example.test'); + + $this->contentService->expects(self::exactly(2)) + ->method('getContents') + ->willReturnOnConsecutiveCalls([$email1, OutputFormat::Html], [$email2, OutputFormat::Text]); + + $this->delivery->expects(self::exactly(2))->method('send'); + $this->notifier->expects(self::exactly(2))->method('notifyForwardSucceeded'); + $this->delivery->expects(self::exactly(2))->method('markSent'); + + // Campaign should increment sent count for both sentAs values + $campaign->expects(self::exactly(2)) + ->method('incrementSentCount') + ->with(self::logicalOr(OutputFormat::Html, OutputFormat::Text)); + + // Stats increment per friend, then update once at the end + $this->stats->expects(self::exactly(2)) + ->method('incrementFriendsCount') + ->with(self::identicalTo($subscriber)); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $service->forward($this->createDto(['x1@example.test', 'x2@example.test']), $campaign); + } + + public function testGetContentsThrowsEmailBlacklistedIsHandledAsFailureAndReportedInResult(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['L1']); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + $this->precache->method('precacheMessage')->willReturn(true); + + $this->contentService->method('getContents')->willThrowException(new EmailBlacklistedException()); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->delivery->expects(self::never())->method('send'); + $this->delivery->expects(self::never())->method('markSent'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once())->method('updateFriendsCount'); + + $result = $service->forward($this->createDto(['friend1@example.test']), $campaign); + + self::assertSame(1, $result->totalRequested); + self::assertSame(0, $result->totalSent); + self::assertSame(1, $result->totalFailed); + self::assertSame(0, $result->totalAlreadySent); + self::assertCount(1, $result->recipients); + self::assertSame('friend1@example.test', $result->recipients[0]->email); + self::assertSame('failed', $result->recipients[0]->status); + self::assertSame('Email address is blacklisted.', $result->recipients[0]->reason); + } + + public function testGetContentsThrowsInvalidRecipientIsHandledAsFailureAndReportedInResult(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn([]); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + $this->precache->method('precacheMessage')->willReturn(true); + + $this->contentService->method('getContents')->willThrowException(new InvalidRecipientOrSubjectException()); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->delivery->expects(self::never())->method('send'); + $this->delivery->expects(self::never())->method('markSent'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once())->method('updateFriendsCount'); + + $result = $service->forward($this->createDto(['friend2@example.test']), $campaign); + + self::assertSame(1, $result->totalRequested); + self::assertSame(0, $result->totalSent); + self::assertSame(1, $result->totalFailed); + self::assertSame(0, $result->totalAlreadySent); + self::assertSame('Invalid recipient or subject.', $result->recipients[0]->reason); + self::assertSame('failed', $result->recipients[0]->status); + } + + public function testPrecacheFailureAlsoReflectedInForwardingResult(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['data' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['LZ']); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + + $this->precache->method('precacheMessage')->willReturn(false); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->contentService->expects(self::never())->method('getContents'); + + $result = $service->forward($this->createDto(['friend3@example.test']), $campaign); + + self::assertSame(1, $result->totalRequested); + self::assertSame(0, $result->totalSent); + self::assertSame(1, $result->totalFailed); + self::assertSame(0, $result->totalAlreadySent); + self::assertSame('precache_failed', $result->recipients[0]->reason); + self::assertSame('failed', $result->recipients[0]->status); + } + + public function testMixedScenarioAggregatesResultsAndSideEffects(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => 1]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['L1', 'L2']); + + // e1 already sent, others not + $this->guard->expects(self::exactly(4)) + ->method('hasAlreadyBeenSent') + ->willReturnOnConsecutiveCalls(true, false, false, false); + + // precache called for e2, e3, e4 + $this->precache->expects(self::exactly(3)) + ->method('precacheMessage') + ->willReturnOnConsecutiveCalls(false, true, true); + + // e3 success, e4 throws + $email3 = (new Email())->to('e3@example.test'); + $this->contentService->expects(self::exactly(2)) + ->method('getContents') + ->willReturnOnConsecutiveCalls( + [$email3, OutputFormat::Html], + self::throwException(new MessageCacheMissingException()) + ); + + // side-effects + $this->delivery->expects(self::once())->method('send'); + $this->delivery->expects(self::once())->method('markSent'); + $this->delivery->expects(self::exactly(2))->method('markFailed'); + $this->notifier->expects(self::once())->method('notifyForwardSucceeded'); + $this->notifier->expects(self::exactly(2))->method('notifyForwardFailed'); + $campaign->expects(self::once()) + ->method('incrementSentCount') + ->with(OutputFormat::Html); + $this->stats->expects(self::once()) + ->method('incrementFriendsCount') + ->with(self::identicalTo($subscriber)); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $dto = $this->createDto(['e1@example.test', 'e2@example.test', 'e3@example.test', 'e4@example.test']); + $result = $service->forward($dto, $campaign); + + self::assertSame(4, $result->totalRequested); + self::assertSame(1, $result->totalSent); + self::assertSame(2, $result->totalFailed); + self::assertSame(1, $result->totalAlreadySent); + + self::assertCount(4, $result->recipients); + self::assertSame('already_sent', $result->recipients[0]->status); + self::assertSame('failed', $result->recipients[1]->status); + self::assertSame('precache_failed', $result->recipients[1]->reason); + self::assertSame('sent', $result->recipients[2]->status); + self::assertSame('failed', $result->recipients[3]->status); + self::assertSame('Message cache is missing or expired.', $result->recipients[3]->reason); + } +} 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); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php index 332b3a7c..4d3e7e3a 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php @@ -5,12 +5,15 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Subscription\Exception\SubscriberAttributeCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; +use PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrManager; +use PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrTablesManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PHPUnit\Framework\TestCase; use Symfony\Component\Translation\Translator; @@ -40,7 +43,7 @@ public function testCreateNewSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $attribute = $manager->createOrUpdate($subscriber, $definition, 'US'); @@ -71,7 +74,7 @@ public function testUpdateExistingSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $result = $manager->createOrUpdate($subscriber, $definition, 'Updated'); @@ -92,7 +95,7 @@ public function testCreateFailsWhenValueAndDefaultAreNull(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $this->expectException(SubscriberAttributeCreationException::class); @@ -116,7 +119,7 @@ public function testGetSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $result = $manager->getSubscriberAttribute(5, 10); @@ -137,7 +140,7 @@ public function testDeleteSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $manager->delete($attribute);