From d0ba2229d54f78552dbb3ab069a16929245910fa Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 9 Feb 2026 13:03:27 +0400 Subject: [PATCH 1/3] Feat: message sending-forwarding (#374) * UserPersonalizer in CampaignProcessorMessageHandler * HtmlToText * MessageDataLoader * TextParser * RemotePageFetcher * Use repo methods * Use MessagePrecacheDto * Refactor * Todo * SystemMailConstructor * EmailBuilder * InjectedByHeaderSubscriber * TemplateImageManager * ExternalImageCacher * TemplateImageEmbedder * Mailer * RemotePageFetcherTest * TextParserTest * MessageDataLoaderTest * MessageDataLoaderTest * Test fix * Fix: phpmd * Fix: phpcs * After review 0 * After review 1 * Add tests * EmailBuilderTest * update coderabbit.yaml * Add tests * MailSizeChecker * Feat/email building with attachments (#375) New Features PDF generation for messages, per-subscriber remote-content fetching, tracking-pixel user tracking, and richer attachment handling with downloadable copies. Improvements Unified email builder flow with consistent composition and multi-format output (HTML/Text/PDF); expanded, context-aware placeholder personalization (many URL/list resolvers); improved remote-content precaching and output formatting; new configurable parameters and translations. --------- Co-authored-by: Tatevik * Feat: email forwarding (#377) - Message forwarding: send campaigns to friends (optional personal note), per-user limits, admin notifications on success/failure, and forwarding statistics; forwarded messages prefixed "Fwd". - Admin-copy emails: configurable toggle to send admin copies and select recipients. --------- Co-authored-by: Tatevik * Cutoff from forward_email_period config * ForwardingResult * Remove MessageFormat consts * Testing bundle * After review 3 * MessageDataLoader types * Fix HTMLPurifier_Config --------- Co-authored-by: Tatevik --- .coderabbit.yaml | 10 +- composer.json | 16 +- config/PHPMD/rules.xml | 8 +- config/PhpCodeSniffer/ruleset.xml | 6 +- config/parameters.yml.dist | 68 ++- config/services.yml | 7 +- config/services/builders.yml | 42 +- config/services/managers.yml | 102 +---- config/services/messenger.yml | 30 +- config/services/parameters.yml | 5 + config/services/providers.yml | 9 +- config/services/repositories.yml | 15 + config/services/resolvers.yml | 24 + config/services/services.yml | 126 +++++- phpunit.xml.dist | 5 + resources/translations/messages.en.xlf | 88 ++++ src/Bounce/Service/LockService.php | 3 - src/Composer/ModuleFinder.php | 26 +- src/Core/Version.php | 10 + .../PhpListCoreExtension.php | 26 ++ .../Analytics/Service/LinkTrackService.php | 12 +- src/Domain/Common/ExternalImageService.php | 223 ++++++++++ src/Domain/Common/FileHelper.php | 64 +++ src/Domain/Common/Html2Text.php | 85 ++++ src/Domain/Common/HtmlUrlRewriter.php | 208 +++++++++ src/Domain/Common/IspRestrictionsProvider.php | 3 +- .../Common/Model/ContentTransferEncoding.php | 14 + src/Domain/Common/OnceCacheGuard.php | 28 ++ src/Domain/Common/PdfGenerator.php | 25 ++ src/Domain/Common/RemotePageFetcher.php | 124 ++++++ src/Domain/Common/TextParser.php | 95 ++++ .../Configuration/Model/ConfigOption.php | 32 ++ .../Model/Dto/PlaceholderContext.php | 47 ++ .../Configuration/Model/OutputFormat.php | 14 + .../Repository/UrlCacheRepository.php | 18 + .../Service/LegacyUrlBuilder.php | 12 +- .../Service/MessagePlaceholderProcessor.php | 139 ++++++ .../Placeholder/BlacklistUrlValueResolver.php | 39 ++ .../Placeholder/BlacklistValueResolver.php | 45 ++ .../ConfirmationUrlValueResolver.php | 37 ++ .../Placeholder/ContactUrlValueResolver.php | 30 ++ .../Placeholder/ContactValueResolver.php | 42 ++ .../Placeholder/FooterValueResolver.php | 37 ++ .../ForwardMessageIdValueResolver.php | 72 +++ .../Placeholder/ForwardUrlValueResolver.php | 43 ++ .../Placeholder/ForwardValueResolver.php | 51 +++ .../Placeholder/JumpoffUrlValueResolver.php | 13 + .../Placeholder/JumpoffValueResolver.php | 40 ++ .../Placeholder/ListsValueResolver.php | 48 ++ .../PatternValueResolverInterface.php | 13 + .../PlaceholderValueResolverInterface.php | 13 + .../PreferencesUrlValueResolver.php | 42 ++ .../Placeholder/PreferencesValueResolver.php | 51 +++ .../Placeholder/SignatureValueResolver.php | 40 ++ .../Placeholder/SubscribeUrlValueResolver.php | 32 ++ .../Placeholder/SubscribeValueResolver.php | 43 ++ ...SupportingPlaceholderResolverInterface.php | 13 + .../UnsubscribeUrlValueResolver.php | 40 ++ .../Placeholder/UnsubscribeValueResolver.php | 50 +++ .../UserDataSupportingResolver.php | 53 +++ .../Placeholder/UserTrackValueResolver.php | 42 ++ .../Service/PlaceholderResolver.php | 101 ++++- .../Service/Provider/ConfigProvider.php | 16 +- .../Provider/DefaultConfigProvider.php | 25 +- .../Service/UserPersonalizer.php | 57 ++- .../Command/ImportDefaultsCommand.php | 2 +- .../Model/AdminAttributeDefinition.php | 16 + src/Domain/Identity/Model/Administrator.php | 15 + .../Model/Dto/AdminAttributeDefinitionDto.php | 3 - .../Model/Dto/CreateAdministratorDto.php | 3 - .../AdminAttributeDefinitionRepository.php | 25 ++ .../Repository/AdministratorRepository.php | 14 + .../Identity/Service/AdminCopyEmailSender.php | 87 ++++ src/Domain/Identity/Service/AdminNotifier.php | 62 +++ .../AdminAttributeDefinitionManager.php | 4 +- .../{ => Manager}/AdminAttributeManager.php | 4 +- .../{ => Manager}/AdministratorManager.php | 2 +- .../Service/{ => Manager}/PasswordManager.php | 6 +- .../Service/{ => Manager}/SessionManager.php | 4 +- .../Messaging/Command/ProcessQueueCommand.php | 3 - .../InjectedByHeaderSubscriber.php | 52 +++ .../Exception/AttachmentCopyException.php | 12 + .../Exception/AttachmentException.php | 15 + .../DevEmailNotConfiguredException.php | 15 + .../Exception/EmailBlacklistedException.php | 15 + .../ForwardLimitExceededException.php | 15 + .../InvalidRecipientOrSubjectException.php | 15 + .../MessageCacheMissingException.php | 15 + .../Exception/MessageNotReceivedException.php | 15 + .../Exception/RemotePageFetchException.php | 15 + .../Exception/SubscriberNotFoundException.php | 12 + .../Message/SubscriberConfirmationMessage.php | 3 - .../SubscriptionConfirmationMessage.php | 3 - .../AsyncEmailMessageHandler.php | 5 +- .../CampaignProcessorMessageHandler.php | 280 ++++++++---- .../PasswordResetMessageHandler.php | 15 +- .../SubscriberConfirmationMessageHandler.php | 15 +- ...SubscriptionConfirmationMessageHandler.php | 51 +-- .../Messaging/Model/Dto/CreateTemplateDto.php | 3 - .../Model/Dto/ForwardingRecipientResult.php | 15 + .../Messaging/Model/Dto/ForwardingResult.php | 20 + .../Messaging/Model/Dto/MessageForwardDto.php | 48 ++ .../Model/Dto/MessagePrecacheDto.php | 31 ++ .../Messaging/Model/Dto/UpdateTemplateDto.php | 3 - src/Domain/Messaging/Model/Message.php | 13 +- .../Messaging/Model/Message/MessageFormat.php | 75 ++-- .../Repository/AttachmentRepository.php | 23 + .../Repository/MessageDataRepository.php | 6 + .../Repository/MessageRepository.php | 21 +- .../Repository/TemplateImageRepository.php | 32 ++ .../Repository/TemplateRepository.php | 6 + .../UserMessageForwardRepository.php | 22 + .../Messaging/Service/AttachmentAdder.php | 248 +++++++++++ .../Service/Builder/BaseEmailBuilder.php | 160 +++++++ .../Service/Builder/EmailBuilder.php | 282 ++++++++++++ .../Service/Builder/ForwardEmailBuilder.php | 142 ++++++ .../Builder/HttpReceivedStampBuilder.php | 66 +++ .../Service/Builder/MessageFormatBuilder.php | 1 - .../Service/Builder/SystemEmailBuilder.php | 163 +++++++ .../CampaignMailContentBuilder.php | 188 ++++++++ .../Constructor/SystemMailContentBuilder.php | 104 +++++ src/Domain/Messaging/Service/EmailService.php | 18 +- .../Service/ForwardContentService.php | 58 +++ .../Service/ForwardDeliveryService.php | 58 +++ .../Messaging/Service/ForwardingGuard.php | 62 +++ .../Service/ForwardingStatsService.php | 87 ++++ .../Messaging/Service/MailSizeChecker.php | 78 ++++ .../Service/Manager/TemplateImageManager.php | 160 ++++++- .../Manager/UserMessageForwardManager.php | 34 ++ .../Messaging/Service/MessageDataLoader.php | 236 ++++++++++ .../Service/MessageForwardService.php | 139 ++++++ .../Service/MessagePrecacheService.php | 276 ++++++++---- .../Service/MessageProcessingPreparator.php | 38 +- .../Service/RateLimitedCampaignMailer.php | 31 +- .../Service/TemplateImageEmbedder.php | 307 +++++++++++++ .../AttributeNotAllowedException.php | 19 + .../Model/Dto/AttributeDefinitionDto.php | 1 - .../Model/Dto/CreateSubscriberListDto.php | 3 - .../Model/Dto/SubscriberImportOptions.php | 3 - src/Domain/Subscription/Model/Subscriber.php | 1 + .../Model/SubscriberAttributeDefinition.php | 14 + .../SubscriberAttributeValueRepository.php | 15 + .../Repository/SubscriberListRepository.php | 38 ++ .../Repository/SubscriberRepository.php | 10 + .../Repository/UserBlacklistRepository.php | 28 ++ .../Service/Manager/SubscribePageManager.php | 3 - .../Manager/SubscriberAttributeManager.php | 36 +- .../Service/SubscriberCsvImporter.php | 40 +- src/Migrations/Version20260204094237.php | 55 +++ src/PhpListCoreBundle.php | 11 + .../Messaging/Fixtures/MessageFixture.php | 63 ++- .../Repository/MessageRepositoryTest.php | 9 +- .../Service/SubscriberDeletionServiceTest.php | 3 +- .../Service/WebklexImapClientFactoryTest.php | 24 +- tests/Unit/Composer/ModuleFinderTest.php | 6 +- .../Service/LinkTrackServiceTest.php | 38 +- tests/Unit/Domain/Common/FileHelperTest.php | 88 ++++ .../Domain/Common/HtmlUrlRewriterTest.php | 121 +++++ .../Unit/Domain/Common/OnceCacheGuardTest.php | 78 ++++ tests/Unit/Domain/Common/PdfGeneratorTest.php | 46 ++ .../Domain/Common/RemotePageFetcherTest.php | 203 +++++++++ tests/Unit/Domain/Common/TextParserTest.php | 69 +++ .../MessagePlaceholderProcessorTest.php | 194 ++++++++ .../BlacklistUrlValueResolverTest.php | 95 ++++ .../BlacklistValueResolverTest.php | 97 ++++ .../ConfirmationUrlValueResolverTest.php | 104 +++++ .../ContactUrlValueResolverTest.php | 103 +++++ .../Placeholder/ContactValueResolverTest.php | 116 +++++ .../Placeholder/FooterValueResolverTest.php | 125 ++++++ .../ForwardMessageIdValueResolverTest.php | 146 ++++++ .../ForwardUrlValueResolverTest.php | 124 ++++++ .../Placeholder/ForwardValueResolverTest.php | 145 ++++++ .../JumpoffUrlValueResolverTest.php | 73 +++ .../Placeholder/JumpoffValueResolverTest.php | 93 ++++ .../Placeholder/ListsValueResolverTest.php | 114 +++++ .../PreferencesUrlValueResolverTest.php | 82 ++++ .../PreferencesValueResolverTest.php | 110 +++++ .../SignatureValueResolverTest.php | 96 ++++ .../SubscribeUrlValueResolverTest.php | 74 ++++ .../SubscribeValueResolverTest.php | 86 ++++ .../UnsubscribeUrlValueResolverTest.php | 95 ++++ .../UnsubscribeValueResolverTest.php | 147 ++++++ .../UserDataSupportingResolverTest.php | 103 +++++ .../UserTrackValueResolverTest.php | 92 ++++ .../Service/PlaceholderResolverTest.php | 25 +- .../Service/Provider/ConfigProviderTest.php | 10 +- .../Provider/DefaultConfigProviderTest.php | 27 +- .../Service/UserPersonalizerTest.php | 55 ++- .../AdminAttributeDefinitionManagerTest.php | 4 +- .../Service/AdminAttributeManagerTest.php | 4 +- .../Service/AdminCopyEmailSenderTest.php | 177 ++++++++ .../Identity/Service/AdminNotifierTest.php | 167 +++++++ .../Service/AdministratorManagerTest.php | 2 +- .../Identity/Service/PasswordManagerTest.php | 10 +- .../Identity/Service/SessionManagerTest.php | 2 +- .../InjectedByHeaderSubscriberTest.php | 89 ++++ .../CampaignProcessorMessageHandlerTest.php | 169 +++++-- ...criptionConfirmationMessageHandlerTest.php | 26 +- .../Domain/Messaging/Model/MessageTest.php | 3 +- .../Messaging/Service/AttachmentAdderTest.php | 234 ++++++++++ .../Service/Builder/EmailBuilderTest.php | 418 ++++++++++++++++++ .../Builder/ForwardEmailBuilderTest.php | 226 ++++++++++ .../Builder/HttpReceivedStampBuilderTest.php | 75 ++++ .../Builder/MessageFormatBuilderTest.php | 1 - .../Builder/SystemEmailBuilderTest.php | 201 +++++++++ .../CampaignMailContentBuilderTest.php | 265 +++++++++++ .../Service/ForwardContentServiceTest.php | 134 ++++++ .../Service/ForwardDeliveryServiceTest.php | 114 +++++ .../Messaging/Service/ForwardingGuardTest.php | 146 ++++++ .../Service/ForwardingStatsServiceTest.php | 119 +++++ .../Messaging/Service/MailSizeCheckerTest.php | 171 +++++++ .../Manager/TemplateImageManagerTest.php | 10 +- .../Manager/UserMessageForwardManagerTest.php | 69 +++ .../Service/MessageDataLoaderTest.php | 140 ++++++ .../Service/MessageForwardServiceTest.php | 332 ++++++++++++++ .../MessageProcessingPreparatorTest.php | 51 +-- .../Service/RateLimitedCampaignMailerTest.php | 93 ---- .../Service/SystemMailConstructorTest.php | 182 ++++++++ .../Service/TemplateImageEmbedderTest.php | 239 ++++++++++ .../SubscriberAttributeManagerTest.php | 13 +- 220 files changed, 13428 insertions(+), 983 deletions(-) create mode 100644 src/Core/Version.php create mode 100644 src/DependencyInjection/PhpListCoreExtension.php create mode 100644 src/Domain/Common/ExternalImageService.php create mode 100644 src/Domain/Common/FileHelper.php create mode 100644 src/Domain/Common/Html2Text.php create mode 100644 src/Domain/Common/HtmlUrlRewriter.php create mode 100644 src/Domain/Common/Model/ContentTransferEncoding.php create mode 100644 src/Domain/Common/OnceCacheGuard.php create mode 100644 src/Domain/Common/PdfGenerator.php create mode 100644 src/Domain/Common/RemotePageFetcher.php create mode 100644 src/Domain/Common/TextParser.php create mode 100644 src/Domain/Configuration/Model/Dto/PlaceholderContext.php create mode 100644 src/Domain/Configuration/Model/OutputFormat.php create mode 100644 src/Domain/Configuration/Service/MessagePlaceholderProcessor.php create mode 100644 src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php create mode 100644 src/Domain/Identity/Service/AdminCopyEmailSender.php create mode 100644 src/Domain/Identity/Service/AdminNotifier.php rename src/Domain/Identity/Service/{ => Manager}/AdminAttributeDefinitionManager.php (98%) rename src/Domain/Identity/Service/{ => Manager}/AdminAttributeManager.php (97%) rename src/Domain/Identity/Service/{ => Manager}/AdministratorManager.php (97%) rename src/Domain/Identity/Service/{ => Manager}/PasswordManager.php (98%) rename src/Domain/Identity/Service/{ => Manager}/SessionManager.php (97%) create mode 100644 src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php create mode 100644 src/Domain/Messaging/Exception/AttachmentCopyException.php create mode 100644 src/Domain/Messaging/Exception/AttachmentException.php create mode 100644 src/Domain/Messaging/Exception/DevEmailNotConfiguredException.php create mode 100644 src/Domain/Messaging/Exception/EmailBlacklistedException.php create mode 100644 src/Domain/Messaging/Exception/ForwardLimitExceededException.php create mode 100644 src/Domain/Messaging/Exception/InvalidRecipientOrSubjectException.php create mode 100644 src/Domain/Messaging/Exception/MessageCacheMissingException.php create mode 100644 src/Domain/Messaging/Exception/MessageNotReceivedException.php create mode 100644 src/Domain/Messaging/Exception/RemotePageFetchException.php create mode 100644 src/Domain/Messaging/Exception/SubscriberNotFoundException.php create mode 100644 src/Domain/Messaging/Model/Dto/ForwardingRecipientResult.php create mode 100644 src/Domain/Messaging/Model/Dto/ForwardingResult.php create mode 100644 src/Domain/Messaging/Model/Dto/MessageForwardDto.php create mode 100644 src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php create mode 100644 src/Domain/Messaging/Service/AttachmentAdder.php create mode 100644 src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php create mode 100644 src/Domain/Messaging/Service/Builder/EmailBuilder.php create mode 100644 src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php create mode 100644 src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php create mode 100644 src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php create mode 100644 src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php create mode 100644 src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php create mode 100644 src/Domain/Messaging/Service/ForwardContentService.php create mode 100644 src/Domain/Messaging/Service/ForwardDeliveryService.php create mode 100644 src/Domain/Messaging/Service/ForwardingGuard.php create mode 100644 src/Domain/Messaging/Service/ForwardingStatsService.php create mode 100644 src/Domain/Messaging/Service/MailSizeChecker.php create mode 100644 src/Domain/Messaging/Service/Manager/UserMessageForwardManager.php create mode 100644 src/Domain/Messaging/Service/MessageDataLoader.php create mode 100644 src/Domain/Messaging/Service/MessageForwardService.php create mode 100644 src/Domain/Messaging/Service/TemplateImageEmbedder.php create mode 100644 src/Domain/Subscription/Exception/AttributeNotAllowedException.php create mode 100644 src/Migrations/Version20260204094237.php create mode 100644 src/PhpListCoreBundle.php create mode 100644 tests/Unit/Domain/Common/FileHelperTest.php create mode 100644 tests/Unit/Domain/Common/HtmlUrlRewriterTest.php create mode 100644 tests/Unit/Domain/Common/OnceCacheGuardTest.php create mode 100644 tests/Unit/Domain/Common/PdfGeneratorTest.php create mode 100644 tests/Unit/Domain/Common/RemotePageFetcherTest.php create mode 100644 tests/Unit/Domain/Common/TextParserTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php create mode 100644 tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php create mode 100644 tests/Unit/Domain/Identity/Service/AdminNotifierTest.php create mode 100644 tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/TemplateImageEmbedderTest.php 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.yml b/config/services.yml index ffb20ce7..7c053ed9 100644 --- a/config/services.yml +++ b/config/services.yml @@ -57,10 +57,9 @@ services: calls: - [ set, [ 'Cache.SerializerPath', '%kernel.cache_dir%/htmlpurifier' ] ] - [ set, [ 'HTML.ForbiddenElements', [ 'script', 'style' ] ] ] - - [ set, [ 'CSS.Disable', true ] ] - - [ set, [ 'URI.DisableJavaScript', true ] ] - - [ set, [ 'URI.DisableDataURI', true ] ] - - [ set, [ 'HTML.Doctype', 'HTML5' ] ] + - [ set, [ 'CSS.AllowedProperties', [] ] ] + - [ set, [ 'URI.AllowedSchemes', { http: true, https: true, mailto: true } ] ] + - [ set, [ 'HTML.Doctype', 'XHTML 1.0 Transitional' ] ] - [ set, [ 'HTML.Allowed', 'p,br,b,strong,i,em,u,a[href|title],ul,ol,li,blockquote,img[src|alt|title],span,div'] ] HTMLPurifier: 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); From 3bd50ae50fe15cba82ce556898553465d72d97b5 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Wed, 11 Feb 2026 14:01:23 +0400 Subject: [PATCH 2/3] AttachmentDownloadService (#379) New Features Attachment download service that validates access, resolves files, detects MIME types, and returns downloadable content. Lightweight downloadable attachment DTO and a new exception for missing attachment files. Public constant to mark forwarded attachments. Bug Fixes Attachment download links now use a path-based format with encoded UID. --- composer.json | 4 +- config/services/services.yml | 4 + .../AttachmentFileNotFoundException.php | 11 ++ src/Domain/Messaging/Model/Attachment.php | 2 + .../Model/Dto/DownloadableAttachment.php | 18 +++ .../Messaging/Service/AttachmentAdder.php | 5 +- .../Service/AttachmentDownloadService.php | 90 +++++++++++++++ .../Messaging/Service/AttachmentAdderTest.php | 2 +- .../Service/AttachmentDownloadServiceTest.php | 109 ++++++++++++++++++ 9 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php create mode 100644 src/Domain/Messaging/Model/Dto/DownloadableAttachment.php create mode 100644 src/Domain/Messaging/Service/AttachmentDownloadService.php create mode 100644 tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php diff --git a/composer.json b/composer.json index 0a39fd1b..f49193ab 100644 --- a/composer.json +++ b/composer.json @@ -86,11 +86,11 @@ "ext-curl": "*", "ext-fileinfo": "*", "setasign/fpdf": "^1.8", - "phpdocumentor/reflection-docblock": "^5.2" + "phpdocumentor/reflection-docblock": "^5.2", + "guzzlehttp/guzzle": "^6.3.0" }, "require-dev": { "phpunit/phpunit": "^9.5", - "guzzlehttp/guzzle": "^6.3.0", "squizlabs/php_codesniffer": "^3.2.0", "phpstan/phpstan": "^1.10", "nette/caching": "^3.0.0", diff --git a/config/services/services.yml b/config/services/services.yml index 93134c38..5e7db66b 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -87,6 +87,10 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\AttachmentDownloadService: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Configuration\Service\UserPersonalizer: autowire: true autoconfigure: true diff --git a/src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php b/src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php new file mode 100644 index 00000000..42510085 --- /dev/null +++ b/src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php @@ -0,0 +1,11 @@ +getTo()[0]->getAddress(); - // todo: add endpoint in rest-api project - $viewUrl = $this->attachmentDownloadUrl . '/?id=' . $att->getId() . '&uid=' . $hash; + $hash = $forwarded ? Attachment::FORWARD : $email->getTo()[0]->getAddress(); + $viewUrl = $this->attachmentDownloadUrl . '/' . $att->getId() . '/?uid=' . urlencode($hash); $email->text( $email->getTextBody() diff --git a/src/Domain/Messaging/Service/AttachmentDownloadService.php b/src/Domain/Messaging/Service/AttachmentDownloadService.php new file mode 100644 index 00000000..00181df6 --- /dev/null +++ b/src/Domain/Messaging/Service/AttachmentDownloadService.php @@ -0,0 +1,90 @@ +validateUid($uid); + + $original = $attachment->getFilename(); + if ($original === null || $original === '') { + throw new AttachmentFileNotFoundException('Attachment has no filename.'); + } + $filename = basename($original); + $filePath = $this->validateFilePath($filename, $original); + + $mimeType = $attachment->getMimeType() + ?? MimeTypes::getDefault()->guessMimeType($filePath) + ?? 'application/octet-stream'; + + $size = filesize($filePath); + $size = $size === false ? null : $size; + + /** @var StreamInterface $stream */ + $stream = Utils::streamFor(Utils::tryFopen($filePath, 'rb')); + + return new DownloadableAttachment( + filename: $filename, + mimeType: $mimeType, + size: $size, + content: $stream, + ); + } + + private function validateUid(string $uid): void + { + if ($uid === Attachment::FORWARD) { + return; + } + + $subscriber = $this->subscriberRepository->findOneByEmail($uid); + if ($subscriber === null) { + throw new SubscriberNotFoundException(); + } + } + + private function validateFilePath(string $filename, ?string $original): string + { + if ($filename === '' || $filename !== $original) { + throw new AttachmentFileNotFoundException('Invalid attachment filename: ' . $original); + } + + $baseDir = realpath($this->attachmentRepositoryPath); + if ($baseDir === false) { + throw new AttachmentFileNotFoundException('Attachment repository path does not exist.'); + } + + $filePath = $baseDir . DIRECTORY_SEPARATOR . $filename; + $realPath = realpath($filePath); + + if ($realPath === false || + !str_starts_with($realPath, $baseDir . DIRECTORY_SEPARATOR) || + !is_file($realPath) || + !is_readable($realPath) + ) { + throw new AttachmentFileNotFoundException('Attachment file not available'); + } + + return $filePath; + } +} diff --git a/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php index 9356eb3d..50a91a51 100644 --- a/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php +++ b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php @@ -91,7 +91,7 @@ public function testTextModePrependsNoticeAndLinks(): void ); $this->assertStringContainsString('Doc description', $body); $this->assertStringContainsString('Location', $body); - $this->assertStringContainsString('https://dl.example/?id=42&uid=user@example.com', $body); + $this->assertStringContainsString('https://dl.example/42/?uid=' . urlencode('user@example.com'), $body); } public function testHtmlUsesRepositoryFileIfExists(): void diff --git a/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php b/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php new file mode 100644 index 00000000..3d165f66 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php @@ -0,0 +1,109 @@ +tempDir = sys_get_temp_dir() . '/phplist-att-dl-' . bin2hex(random_bytes(5)); + if (!is_dir($this->tempDir)) { + mkdir($this->tempDir, 0777, true); + } + } + + protected function tearDown(): void + { + // cleanup temp directory + if (is_dir($this->tempDir)) { + $files = scandir($this->tempDir) ?: []; + foreach ($files as $f) { + if ($f === '.' || $f === '..') { + continue; + } + unlink($this->tempDir . '/' . $f); + } + rmdir($this->tempDir); + } + } + + public function testThrowsWhenFilenameIsEmpty(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn(''); + + $this->expectException(AttachmentFileNotFoundException::class); + $service->getDownloadable($attachment, 'forwarded'); + } + + public function testThrowsWhenFileDoesNotExist(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn('missing-file.pdf'); + + $this->expectException(AttachmentFileNotFoundException::class); + $service->getDownloadable($attachment, 'forwarded'); + } + + public function testReturnsDownloadableWithExplicitMimeType(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $subscriberRepo->method('findOneByEmail')->with('user@example.com')->willReturn(new Subscriber()); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $filename = 'doc.pdf'; + $content = '%PDF-1.4\n'; + file_put_contents($this->tempDir . '/' . $filename, $content); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn($filename); + $attachment->method('getMimeType')->willReturn('application/pdf'); + + $dl = $service->getDownloadable($attachment, 'user@example.com'); + + $this->assertSame($filename, $dl->filename); + $this->assertSame('application/pdf', $dl->mimeType); + $this->assertSame(strlen($content), $dl->size); + $this->assertSame($content, (string)$dl->content); + } + + public function testGuessesMimeTypeAndProvidesStream(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $subscriberRepo->method('findOneByEmail')->with('user@example.com')->willReturn(new Subscriber()); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $filename = 'note.txt'; + $content = "Hello, world!\n"; + file_put_contents($this->tempDir . '/' . $filename, $content); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn($filename); + $attachment->method('getMimeType')->willReturn(null); + + $dl = $service->getDownloadable($attachment, 'user@example.com'); + + $this->assertSame($filename, $dl->filename); + // Symfony MimeTypes should detect text/plain for .txt + $this->assertSame('text/plain', $dl->mimeType); + $this->assertSame(strlen($content), $dl->size); + $this->assertSame($content, (string)$dl->content); + } +} From 1770948b4813bf43ff9ba98fe0e5c9ec46cb9217 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 13 Feb 2026 12:24:11 +0400 Subject: [PATCH 3/3] Feat: user message open tracking (#380) New Features Message view tracking: records when subscribers view messages and captures metadata (IP, User-Agent, referer). Enhancements Quick actions to mark messages as viewed and to check viewed status. Message view counters now increment when viewed. Refactor Repository lookup renamed for consistency and callers updated. Chores Switched REST API parameter to a base URL and updated tracking image path. --- config/parameters.yml.dist | 4 +- config/services/services.yml | 4 + .../Analytics/Model/UserMessageView.php | 5 + .../Analytics/Service/UserMessageService.php | 56 +++++ .../Placeholder/UserTrackValueResolver.php | 6 +- .../CampaignProcessorMessageHandler.php | 2 +- .../Model/Message/MessageMetadata.php | 7 + src/Domain/Messaging/Model/UserMessage.php | 10 + .../Repository/MessageRepository.php | 10 + .../Repository/UserMessageRepository.php | 2 +- .../Builder/HttpReceivedStampBuilder.php | 4 +- .../Messaging/Service/ForwardingGuard.php | 2 +- .../Service/UserMessageServiceTest.php | 192 ++++++++++++++++++ .../UserTrackValueResolverTest.php | 4 +- .../Messaging/Service/ForwardingGuardTest.php | 6 +- 15 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 src/Domain/Analytics/Service/UserMessageService.php create mode 100644 tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 628f1e45..6de7d2ef 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -33,8 +33,8 @@ parameters: 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' + app.rest_api_base_url: '%%env(REST_API_BASE_URL)%%' + env(REST_API_BASE_URL): 'https://example.com/api/v2' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' diff --git a/config/services/services.yml b/config/services/services.yml index 5e7db66b..53a5f84d 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -53,6 +53,10 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Analytics\Service\UserMessageService: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\SendRateLimiter: autowire: true autoconfigure: true diff --git a/src/Domain/Analytics/Model/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php index 846c8e97..b391d3f3 100644 --- a/src/Domain/Analytics/Model/UserMessageView.php +++ b/src/Domain/Analytics/Model/UserMessageView.php @@ -85,6 +85,11 @@ public function setViewed(?DateTime $viewed): self return $this; } + public function setViewedNow(): self + { + return $this->setViewed(new DateTime()); + } + public function setIp(?string $ip): self { $this->ip = $ip; diff --git a/src/Domain/Analytics/Service/UserMessageService.php b/src/Domain/Analytics/Service/UserMessageService.php new file mode 100644 index 00000000..c5a1cc5b --- /dev/null +++ b/src/Domain/Analytics/Service/UserMessageService.php @@ -0,0 +1,56 @@ +subscriberRepository->findOneByUniqueId($uid); + $message = $this->messageRepository->findById($messageId); + + if ($subscriber === null || $message === null) { + return; + } + + $userMessage = $this->userMessageRepository->findByUserAndMessage($subscriber, $message); + if ($userMessage === null) { + return; + } + + $userMessage->setViewedNow(); + $message->getMetadata()->incrementViews(); + + $data = []; + foreach (['HTTP_USER_AGENT', 'HTTP_REFERER'] as $key) { + if (isset($metadata[$key])) { + $data[$key] = htmlspecialchars(strip_tags($metadata[$key])); + } + } + + $userMessageView = new UserMessageView(); + $userMessageView->setUserId($subscriber->getId()); + $userMessageView->setMessageId($messageId); + $userMessageView->setViewedNow(); + $userMessageView->setIp($metadata['client_ip'] ?? null); + $userMessageView->setData(serialize($data)); + + $this->entityManager->persist($userMessageView); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php index 4983fc28..8a5b626d 100644 --- a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -13,7 +13,7 @@ final class UserTrackValueResolver implements PlaceholderValueResolverInterface { public function __construct( private readonly ConfigProvider $config, - #[Autowire('%rest_api_domain%')] private readonly string $restApiDomain, + #[Autowire('%app.rest_api_base_url%')] private readonly string $restApiBaseUrl, ) { } @@ -24,7 +24,7 @@ public function name(): string public function __invoke(PlaceholderContext $ctx): string { - $base = $this->config->getValue(ConfigOption::Domain) ?? $this->restApiDomain; + $base = $this->config->getValue(ConfigOption::Domain) ?? $this->restApiBaseUrl; if ($ctx->isText() || empty($base)) { return ''; @@ -33,7 +33,7 @@ public function __invoke(PlaceholderContext $ctx): string return ''; + $expected = ''; // Normalize double quotes for comparison $this->assertSame($expected, $result); } @@ -86,7 +86,7 @@ public function testHtmlFallsBackToRestApiDomainWhenConfigMissing(): void $result = $resolver($ctx); - $expected = ''; + $expected = ''; $this->assertSame($expected, $result); } } diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php index 8ac266f8..b1ee10b9 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php @@ -46,7 +46,7 @@ public function testAssertCanForwardReturnsSubscriber(): void $subscriber = new Subscriber(); $this->subscriberRepo->method('findOneByUniqueId')->with($uid)->willReturn($subscriber); - $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn( + $this->userMessageRepo->method('findByUserAndMessage')->willReturn( $this->createMock(UserMessage::class) ); $this->forwardRepo->method('getCountByUserSince')->willReturn(1); @@ -82,7 +82,7 @@ public function testAssertCanForwardThrowsWhenMessageNotReceived(): void ); $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); - $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn(null); + $this->userMessageRepo->method('findByUserAndMessage')->willReturn(null); $this->expectException(MessageNotReceivedException::class); $guard->assertCanForward('uid', $this->createMock(Message::class)); @@ -99,7 +99,7 @@ public function testAssertCanForwardThrowsWhenLimitExceeded(): void ); $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); - $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn($this->createMock(UserMessage::class)); + $this->userMessageRepo->method('findByUserAndMessage')->willReturn($this->createMock(UserMessage::class)); $this->forwardRepo->method('getCountByUserSince')->willReturn(2); $this->expectException(ForwardLimitExceededException::class);