From d0ba2229d54f78552dbb3ab069a16929245910fa Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 9 Feb 2026 13:03:27 +0400 Subject: [PATCH 1/4] 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/4] 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/4] 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); From 3a1ab4e9e360508bf259fd2b71aea27908fa88e4 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 16 Feb 2026 13:22:01 +0400 Subject: [PATCH 4/4] Remove 'to' from MessagePrecacheDto (#381) Refactor Recipient addresses are now passed explicitly throughout the email pipeline for clearer, more reliable message construction and handling. Subscriber creation now requires an email at instantiation, enforcing consistent initialization. Bug Fixes Admin notification filtering relaxed so system notifications are handled more consistently even when recipient info is missing. --- .coderabbit.yaml | 3 + .../Identity/Service/AdminCopyEmailSender.php | 3 +- .../CampaignProcessorMessageHandler.php | 5 +- .../Model/Dto/MessagePrecacheDto.php | 3 - .../Service/Builder/BaseEmailBuilder.php | 3 +- .../Service/Builder/EmailBuilder.php | 28 +++++++-- .../Service/Builder/ForwardEmailBuilder.php | 3 + .../Service/Builder/SystemEmailBuilder.php | 18 +++--- .../CampaignMailContentBuilder.php | 18 ++---- .../Messaging/Service/MessageDataLoader.php | 1 - .../Service/MessagePrecacheService.php | 1 - src/Domain/Subscription/Model/Subscriber.php | 3 +- .../Service/Manager/SubscriberManager.php | 6 +- .../Fixtures/SubscriberFixture.php | 3 +- .../Repository/SubscriberRepositoryTest.php | 16 ++---- .../SubscriberCsvExportManagerTest.php | 6 +- .../SubscriberCsvImportManagerTest.php | 3 +- .../Service/SubscriberDeletionServiceTest.php | 3 +- .../MessagePlaceholderProcessorTest.php | 3 +- .../BlacklistUrlValueResolverTest.php | 3 +- .../BlacklistValueResolverTest.php | 3 +- .../ConfirmationUrlValueResolverTest.php | 3 +- .../ContactUrlValueResolverTest.php | 3 +- .../Placeholder/ContactValueResolverTest.php | 3 +- .../Placeholder/FooterValueResolverTest.php | 9 ++- .../ForwardMessageIdValueResolverTest.php | 3 +- .../ForwardUrlValueResolverTest.php | 3 +- .../Placeholder/ForwardValueResolverTest.php | 3 +- .../JumpoffUrlValueResolverTest.php | 3 +- .../Placeholder/JumpoffValueResolverTest.php | 3 +- .../Placeholder/ListsValueResolverTest.php | 3 +- .../PreferencesUrlValueResolverTest.php | 3 +- .../PreferencesValueResolverTest.php | 3 +- .../SignatureValueResolverTest.php | 3 +- .../SubscribeUrlValueResolverTest.php | 3 +- .../SubscribeValueResolverTest.php | 3 +- .../UnsubscribeUrlValueResolverTest.php | 3 +- .../UnsubscribeValueResolverTest.php | 5 +- .../UserDataSupportingResolverTest.php | 3 +- .../UserTrackValueResolverTest.php | 3 +- .../Service/AdminCopyEmailSenderTest.php | 5 +- .../Identity/Service/AdminNotifierTest.php | 6 +- .../Messaging/Model/SubscriberListTest.php | 2 +- .../Service/AttachmentDownloadServiceTest.php | 10 +++- .../Service/Builder/EmailBuilderTest.php | 57 ++++++++++++------- .../Builder/ForwardEmailBuilderTest.php | 6 +- .../Builder/SystemEmailBuilderTest.php | 11 ++-- .../CampaignMailContentBuilderTest.php | 29 ++-------- .../Service/ForwardContentServiceTest.php | 4 +- .../Service/ForwardDeliveryServiceTest.php | 4 +- .../Messaging/Service/ForwardingGuardTest.php | 6 +- .../Service/Manager/BounceManagerTest.php | 2 +- .../Service/MessageForwardServiceTest.php | 14 ++--- .../Subscription/Model/SubscriberTest.php | 2 +- .../Subscription/Model/SubscriptionTest.php | 2 +- .../SubscriberAttributeManagerTest.php | 11 ++-- .../SubscriberBlacklistManagerTest.php | 1 + .../Manager/SubscriptionManagerTest.php | 4 +- .../CheckboxGroupValueProviderTest.php | 2 +- ...bscriberAttributeChangeSetProviderTest.php | 12 ++-- 60 files changed, 185 insertions(+), 205 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 38549c12..6305f1b4 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -8,6 +8,9 @@ reviews: suggested_labels: false high_level_summary_in_walkthrough: false poem: false + finishing_touches: + docstrings: + enabled: false path_instructions: - path: "src/Domain/**" instructions: | diff --git a/src/Domain/Identity/Service/AdminCopyEmailSender.php b/src/Domain/Identity/Service/AdminCopyEmailSender.php index a1de0b0c..64e94645 100644 --- a/src/Domain/Identity/Service/AdminCopyEmailSender.php +++ b/src/Domain/Identity/Service/AdminCopyEmailSender.php @@ -39,11 +39,10 @@ public function __invoke(string $subject, string $message, array $lists = []): v foreach ($mails as $adminMail) { $data = new MessagePrecacheDto(); - $data->to = $adminMail; $data->subject = $this->installationName . ' ' . $subject; $data->content = $message; - $email = $this->systemEmailBuilder->buildSystemEmail(data: $data); + $email = $this->systemEmailBuilder->buildSystemEmail(data: $data, toEmail: $adminMail); if ($email === null) { $this->logger->warning('Failed to build admin copy email for recipient ' . $adminMail); continue; diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index b8837a66..faf4aef7 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -210,6 +210,7 @@ private function handleEmailSending( $result = $this->campaignEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), data: $processed, + toEmail: $subscriber->getEmail(), skipBlacklistCheck: false, inBlast: true, htmlPref: $subscriber->hasHtmlEmail(), @@ -236,13 +237,13 @@ private function handleEmailSending( $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); $data = new MessagePrecacheDto(); - $data->to = $this->configProvider->getValue(ConfigOption::ReportAddress); $data->subject = $this->translator->trans('phpList system error'); $data->content = $this->translator->trans($e->getMessage()); $email = $this->systemEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), data: $data, + toEmail: $this->configProvider->getValue(ConfigOption::ReportAddress) ?? '', ); $envelope = new Envelope( @@ -270,7 +271,6 @@ private function handleAdminNotifications(Message $campaign, array $loadedMessag $notifications = explode(',', $loadedMessageData['notify_start']); foreach ($notifications as $notification) { $data = new MessagePrecacheDto(); - $data->to = $notification; $data->subject = $this->translator->trans('Campaign started'); $data->content = $this->translator->trans( 'phplist has started sending the campaign with subject %subject%', @@ -280,6 +280,7 @@ private function handleAdminNotifications(Message $campaign, array $loadedMessag $email = $this->systemEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), data: $data, + toEmail: $notification ); if (!$email) { diff --git a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php index 134f1a59..3b0f0d24 100644 --- a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php +++ b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php @@ -11,7 +11,6 @@ class MessagePrecacheDto public ?string $replyToName = null; public ?string $fromName = null; public ?string $fromEmail = null; - public ?string $to = null; public string $subject = ''; public string $content = ''; public string $textContent = ''; @@ -23,8 +22,6 @@ class MessagePrecacheDto public ?string $template = null; public ?string $templateText = null; public ?int $templateId = null; -// public string $htmlCharset= 'UTF-8'; -// public string $textCharset= 'UTF-8'; public bool $userSpecificUrl = false; public bool $googleTrack = false; public array $adminAttributes = []; diff --git a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php index 79bffa9a..0c7d9637 100644 --- a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php @@ -61,7 +61,6 @@ protected function validateRecipientAndSubject(?string $to, ?string $subject): b protected function passesBlacklistCheck(string $to, ?bool $skipBlacklistCheck): bool { - if (!$skipBlacklistCheck && $this->blacklistRepository->isEmailBlacklisted($to)) { $this->eventLogManager->log('', sprintf('Error, %s is blacklisted, not sending', $to)); $subscriber = $this->subscriberRepository->findOneByEmail($to); @@ -103,7 +102,7 @@ protected function createBaseEmail( ?string $fromEmail, ?string $fromName, ?string $subject, - ) : Email { + ): Email { $email = (new Email()); $destinationEmail = $this->resolveDestinationEmail($to); diff --git a/src/Domain/Messaging/Service/Builder/EmailBuilder.php b/src/Domain/Messaging/Service/Builder/EmailBuilder.php index d0cf8039..c863bf31 100644 --- a/src/Domain/Messaging/Service/Builder/EmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/EmailBuilder.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Exception\AttachmentException; +use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; use PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder; @@ -65,20 +66,24 @@ public function __construct( ); } - /** @return array{Email, OutputFormat}|null */ + /** + * @return array{Email, OutputFormat}|null + * @throws SubscriberNotFoundException + */ public function buildCampaignEmail( int $messageId, MessagePrecacheDto $data, + string $toEmail, ?bool $skipBlacklistCheck = false, ?bool $inBlast = true, ?bool $htmlPref = false, ?bool $isTestMail = false, ): ?array { - if (!$this->validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { + if (!$this->validateRecipientAndSubject(to: $toEmail, subject: $data->subject)) { return null; } - if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { + if (!$this->passesBlacklistCheck(to: $toEmail, skipBlacklistCheck: $skipBlacklistCheck)) { return null; } @@ -87,7 +92,7 @@ public function buildCampaignEmail( $subject = (!$isTestMail ? '' : $this->translator->trans('(test)') . ' ') . $data->subject; $email = $this->createBaseEmail( - to: $data->to, + to: $toEmail, fromEmail: $fromEmail, fromName: $fromName, subject: $subject, @@ -95,7 +100,7 @@ public function buildCampaignEmail( $this->addBaseCampaignHeaders( email: $email, messageId: $messageId, - originalTo: $data->to, + originalTo: $toEmail, destinationEmail: $email->getTo()[0]->getAddress(), inBlast: $inBlast, ); @@ -109,7 +114,18 @@ public function buildCampaignEmail( } } - [$htmlMessage, $textMessage] = ($this->mailContentBuilder)(messagePrecacheDto: $data, campaignId: $messageId); + $receiver = $this->subscriberRepository->findOneByEmail($toEmail); + if (!$receiver) { + throw new SubscriberNotFoundException( + sprintf('Subscriber with email %s not found', $toEmail) + ); + } + + [$htmlMessage, $textMessage] = ($this->mailContentBuilder)( + messagePrecacheDto: $data, + receiver: $receiver, + campaignId: $messageId, + ); $sentAs = $this->applyContentAndFormatting( email: $email, htmlMessage: $htmlMessage, diff --git a/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php index 7a0c35b5..778e62af 100644 --- a/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php @@ -96,8 +96,11 @@ public function buildForwardEmail( $subject = $this->translator->trans('Fwd') . ': ' . stripslashes($data->subject); + $receiver = $this->subscriberRepository->findOneByEmail($friendEmail) ?? (new Subscriber($friendEmail)); + [$htmlMessage, $textMessage] = ($this->mailContentBuilder)( messagePrecacheDto: $data, + receiver: $receiver, campaignId: $messageId, forwardedBy: $forwardedBy, forwardedPersonalNote: $forwardedPersonalNote, diff --git a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php index 855c5950..cc50d12b 100644 --- a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php @@ -53,13 +53,14 @@ public function __construct( public function buildCampaignEmail( int $messageId, MessagePrecacheDto $data, + string $toEmail, ?bool $skipBlacklistCheck = false, ): ?Email { - if (!$this->validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { + if (!$this->validateRecipientAndSubject(to: $toEmail, subject: $data->subject)) { return null; } - if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { + if (!$this->passesBlacklistCheck(to: $toEmail, skipBlacklistCheck: $skipBlacklistCheck)) { return null; } @@ -69,7 +70,7 @@ public function buildCampaignEmail( $replyTo = $messageReplyToAddress ?: $fromEmail; $email = $this->createBaseEmail( - to: $data->to, + to: $toEmail, fromEmail: $fromEmail, fromName: $fromName, subject: $data->subject, @@ -79,7 +80,7 @@ public function buildCampaignEmail( $this->addBaseCampaignHeaders( email: $email, messageId: $messageId, - originalTo: $data->to, + originalTo: $toEmail, destinationEmail: $email->getTo()[0]->getAddress(), inBlast: false, ); @@ -97,13 +98,14 @@ public function buildCampaignEmail( public function buildSystemEmail( MessagePrecacheDto $data, + string $toEmail, ?bool $skipBlacklistCheck = false, ): ?Email { - if (!$this->validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { + if (!$this->validateRecipientAndSubject(to: $toEmail, subject: $data->subject)) { return null; } - if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { + if (!$this->passesBlacklistCheck(to: $toEmail, skipBlacklistCheck: $skipBlacklistCheck)) { return null; } @@ -113,14 +115,14 @@ public function buildSystemEmail( $replyTo = $messageReplyToAddress ?: $fromEmail; $email = $this->createBaseEmail( - to: $data->to, + to: $toEmail, fromEmail: $fromEmail, fromName: $fromName, subject: $data->subject, ); $email->replyTo($replyTo); - $this->addSystemHeaders(email: $email, originalTo: $data->to,); + $this->addSystemHeaders(email: $email, originalTo: $toEmail); [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); $email->text($textMessage); diff --git a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php index c166b112..c7398a20 100644 --- a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php +++ b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php @@ -16,7 +16,6 @@ use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; -use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException; use Symfony\Component\DependencyInjection\Attribute\Autowire; class CampaignMailContentBuilder @@ -35,21 +34,16 @@ public function __construct( public function __invoke( MessagePrecacheDto $messagePrecacheDto, + Subscriber $receiver, ?int $campaignId = null, ?Subscriber $forwardedBy = null, ?string $forwardedPersonalNote = null, ): array { - $subscriber = $this->subscriberRepository->findOneByEmail($messagePrecacheDto->to); - if (!$subscriber) { - throw new SubscriberNotFoundException( - sprintf('Subscriber with email %s not found', $messagePrecacheDto->to) - ); - } $addDefaultStyle = false; - if ($messagePrecacheDto->userSpecificUrl) { - $userData = $this->subscriberRepository->getDataById($subscriber->getId()); - $this->replaceUserSpecificRemoteContent($messagePrecacheDto, $subscriber, $userData); + if ($messagePrecacheDto->userSpecificUrl && $receiver->getId()) { + $userData = $this->subscriberRepository->getDataById($receiver->getId()); + $this->replaceUserSpecificRemoteContent($messagePrecacheDto, $receiver, $userData); } $content = $messagePrecacheDto->content; @@ -80,7 +74,7 @@ public function __invoke( $textMessage = $this->placeholderProcessor->process( value: $textMessage, - receiver: $subscriber, + receiver: $receiver, format: OutputFormat::Text, messagePrecacheDto: $messagePrecacheDto, campaignId: $campaignId, @@ -89,7 +83,7 @@ public function __invoke( $htmlMessage = $this->placeholderProcessor->process( value: $htmlMessage, - receiver: $subscriber, + receiver: $receiver, format: OutputFormat::Html, messagePrecacheDto: $messagePrecacheDto, campaignId: $campaignId, diff --git a/src/Domain/Messaging/Service/MessageDataLoader.php b/src/Domain/Messaging/Service/MessageDataLoader.php index b47efbae..c3577bec 100644 --- a/src/Domain/Messaging/Service/MessageDataLoader.php +++ b/src/Domain/Messaging/Service/MessageDataLoader.php @@ -82,7 +82,6 @@ private function buildDefaultMessageData(): array 'footer' => $this->configProvider->getValue(ConfigOption::MessageFooter), 'forwardfooter' => $this->configProvider->getValue(ConfigOption::ForwardFooter), 'status' => '', - 'tofield' => '', 'replyto' => '', 'targetlist' => [], 'criteria_match' => '', diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index dd637283..dfab25ec 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -135,7 +135,6 @@ private function populateBasicFields( ): void { $messagePrecacheDto->fromName = $loadedMessageData['fromname']; $messagePrecacheDto->fromEmail = $loadedMessageData['fromemail']; - $messagePrecacheDto->to = $loadedMessageData['tofield']; //0013076: different content when forwarding 'to a friend' $messagePrecacheDto->subject = $forwardContent diff --git a/src/Domain/Subscription/Model/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php index 967f7131..c80aefd8 100644 --- a/src/Domain/Subscription/Model/Subscriber.php +++ b/src/Domain/Subscription/Model/Subscriber.php @@ -109,8 +109,9 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat #[ORM\Column(name: 'foreignkey', type: 'string', length: 100, nullable: true)] private ?string $foreignKey = null; - public function __construct() + public function __construct(string $email) { + $this->email = $email; $this->subscriptions = new ArrayCollection(); $this->attributes = new ArrayCollection(); $this->extraData = ''; diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 9584b512..39a3ee84 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -40,8 +40,7 @@ public function __construct( public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber { - $subscriber = new Subscriber(); - $subscriber->setEmail($subscriberDto->email); + $subscriber = new Subscriber($subscriberDto->email); $confirmed = (bool)$subscriberDto->requestConfirmation; $subscriber->setConfirmed(!$confirmed); $subscriber->setBlacklisted(false); @@ -106,8 +105,7 @@ public function deleteSubscriber(Subscriber $subscriber): void public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber { - $subscriber = new Subscriber(); - $subscriber->setEmail($subscriberDto->email); + $subscriber = new Subscriber($subscriberDto->email); $subscriber->setConfirmed($subscriberDto->confirmed); $subscriber->setBlacklisted($subscriberDto->blacklisted); $subscriber->setHtmlEmail($subscriberDto->htmlEmail); diff --git a/tests/Integration/Domain/Subscription/Fixtures/SubscriberFixture.php b/tests/Integration/Domain/Subscription/Fixtures/SubscriberFixture.php index 9c74dd1b..a6da2128 100644 --- a/tests/Integration/Domain/Subscription/Fixtures/SubscriberFixture.php +++ b/tests/Integration/Domain/Subscription/Fixtures/SubscriberFixture.php @@ -37,10 +37,9 @@ public function load(ObjectManager $manager): void } $row = array_combine($headers, $data); - $subscriber = new Subscriber(); + $subscriber = new Subscriber($row['email']); $this->setSubjectId($subscriber, (int)$row['id']); - $subscriber->setEmail($row['email']); $subscriber->setConfirmed((bool) $row['confirmed']); $subscriber->setBlacklisted((bool) $row['blacklisted']); $subscriber->setBounceCount((int) $row['bouncecount']); diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php index 9a2ead68..2fdff18b 100644 --- a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php @@ -79,8 +79,7 @@ public function testFindReadsModelFromDatabase() public function testCreationDateOfNewModelIsSetToNowOnPersist() { - $model = new Subscriber(); - $model->setEmail('sam@example.com'); + $model = new Subscriber('sam@example.com'); $expectedCreationDate = new DateTime(); $this->entityManager->persist($model); @@ -90,8 +89,7 @@ public function testCreationDateOfNewModelIsSetToNowOnPersist() public function testModificationDateOfNewModelIsSetToNowOnPersist() { - $model = new Subscriber(); - $model->setEmail('oliver@example.com'); + $model = new Subscriber('oliver@example.com'); $expectedModificationDate = new DateTime(); $this->entityManager->persist($model); @@ -101,8 +99,7 @@ public function testModificationDateOfNewModelIsSetToNowOnPersist() public function testSavePersistsAndFlushesModel() { - $model = new Subscriber(); - $model->setEmail('michiel@example.com'); + $model = new Subscriber('michiel@example.com'); $this->subscriberRepository->save($model); self::assertSame($model, $this->subscriberRepository->find($model->getId())); @@ -115,9 +112,8 @@ public function testEmailMustBeUnique() /** @var Subscriber $model */ $model = $this->subscriberRepository->find(1); - $otherModel = new Subscriber(); + $otherModel = new Subscriber($model->getEmail()); $otherModel->generateUniqueId(); - $otherModel->setEmail($model->getEmail()); $this->expectException(UniqueConstraintViolationException::class); @@ -126,9 +122,7 @@ public function testEmailMustBeUnique() public function testUniqueIdOfNewModelIsGeneratedOnPersist() { - $model = new Subscriber(); - $model->setEmail('oliver@example.com'); - + $model = new Subscriber('oliver@example.com'); $this->entityManager->persist($model); self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $model->getUniqueId()); diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php index c2ca4f7d..ada5e2cd 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php @@ -35,8 +35,7 @@ protected function setUp(): void public function testExportToCsvReturnsStreamedResponse(): void { - $subscriber1 = new Subscriber(); - $subscriber1->setEmail('test1@example.com'); + $subscriber1 = new Subscriber('test1@example.com'); $subscriber1->setConfirmed(true); $subscriber1->setHtmlEmail(true); $subscriber1->setBlacklisted(false); @@ -44,8 +43,7 @@ public function testExportToCsvReturnsStreamedResponse(): void $subscriber1->setExtraData('Data 1'); $this->entityManager->persist($subscriber1); - $subscriber2 = new Subscriber(); - $subscriber2->setEmail('test2@example.com'); + $subscriber2 = new Subscriber('test2@example.com'); $subscriber2->setConfirmed(false); $subscriber2->setHtmlEmail(false); $subscriber2->setBlacklisted(true); diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php index c9d60ae3..9c27c1c9 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -95,8 +95,7 @@ public function testImportFromCsvCreatesNewSubscribers(): void public function testImportFromCsvUpdatesExistingSubscribers(): void { - $subscriber = new Subscriber(); - $subscriber->setEmail('existing@example.com'); + $subscriber = new Subscriber('existing@example.com'); $subscriber->setConfirmed(false); $subscriber->setHtmlEmail(false); $subscriber->setBlacklisted(true); diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index 785d3547..3a7ae27b 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -66,8 +66,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): ); $this->entityManager->persist($msg); - $subscriber = new Subscriber(); - $subscriber->setEmail('test-delete@example.com'); + $subscriber = new Subscriber('test-delete@example.com'); $subscriber->setConfirmed(true); $subscriber->setHtmlEmail(true); $subscriber->setBlacklisted(false); diff --git a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php index 2e67097f..2d49fa39 100644 --- a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php +++ b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php @@ -35,8 +35,7 @@ protected function setUp(): void private function makeUser(string $email = 'user@example.com', string $uid = 'UID123'): Subscriber { - $user = new Subscriber(); - $user->setEmail($email); + $user = new Subscriber($email); $user->setUniqueId($uid); return $user; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php index 49eb567b..d77de0a3 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(string $email = 'user@example.com'): Subscriber { - $u = new Subscriber(); - $u->setEmail($email); + $u = new Subscriber($email); $u->setUniqueId('UID-123'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php index 7607270b..e81684a7 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php @@ -30,8 +30,7 @@ protected function setUp(): void private function makeUser(string $email = 'user@example.com'): Subscriber { - $u = new Subscriber(); - $u->setEmail($email); + $u = new Subscriber($email); $u->setUniqueId('UID-1'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php index e9278a42..589d7cdc 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeUser(string $uid = 'UID-1'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php index 07801cec..950e1618 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeUser(): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId('UID-1'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php index e11a1365..1e378f7a 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId('UID-C'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php index 1508cf3d..a9333b73 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php @@ -25,8 +25,7 @@ protected function setUp(): void private function makeUser(string $email = 'user@example.com', string $uid = 'UID-1'): Subscriber { - $u = new Subscriber(); - $u->setEmail($email); + $u = new Subscriber($email); $u->setUniqueId($uid); return $u; } @@ -84,7 +83,7 @@ public function testForwardedAlternativeUsesStripslashesFooter(): void user: $this->makeUser(), format: OutputFormat::Text, messagePrecacheDto: $dto, - forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + forwardedBy: (new Subscriber('fwd@example.com')), ); $this->assertSame(stripslashes($raw), $resolver($ctx)); } @@ -100,7 +99,7 @@ public function testForwardedUsesConfigForwardFooterWhenFlagFalse(): void user: $this->makeUser(), format: OutputFormat::Html, messagePrecacheDto: $this->makeDto('TF', 'HF', 'Alt'), - forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + forwardedBy: (new Subscriber('fwd@example.com')), ); $this->assertSame('Forward footer set by config', $resolver($ctx)); @@ -117,7 +116,7 @@ public function testForwardedFallsBackToEmptyWhenConfigNull(): void user: $this->makeUser(), format: OutputFormat::Html, messagePrecacheDto: $this->makeDto('TF', 'HF', 'Alt'), - forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + forwardedBy: (new Subscriber('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 index 7fda2280..6ee9bfdf 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(string $uid = 'U-FWD'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php index dac64444..d66aae02 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeUser(string $uid = 'U1'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php index 944cf1ef..287bf768 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(string $uid = 'UID-F'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php index 842e6f8e..c89d3578 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(string $uid = 'UID-JOU'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php index 521bd06b..4cc3f6e3 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(string $uid = 'UID-JO'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php index df0f2224..acd82866 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php @@ -26,8 +26,7 @@ protected function setUp(): void private function makeUser(): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId('UID-L'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php index 24fc705d..b2ed1bd0 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeUser(): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId('UID-PREF'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php index f59649d8..4a370033 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(string $uid = 'UID-PREV'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php index 03928966..c7186ca5 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeUser(): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId('UID-SIG'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php index 530a1dc5..05d78062 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeUser(): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId('UID-SUB'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php index b37774e1..ebf01e27 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId('UID-SV'); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php index 4ca95e8c..22db0726 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php @@ -27,8 +27,7 @@ protected function setUp(): void private function makeUser(string $uid = 'UID-UNSUB'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php index 3ae672eb..48f31be9 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php @@ -30,8 +30,7 @@ protected function setUp(): void private function makeUser(string $uid = 'UID-U'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } @@ -120,7 +119,7 @@ public function testForwardedByUsesBlacklistUrl(): void format: OutputFormat::Html, messagePrecacheDto: null, locale: 'en', - forwardedBy: (new Subscriber())->setEmail('someone@example.com'), + forwardedBy: (new Subscriber('someone@example.com')), ); $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php index 8a757cd4..9d0c2bb5 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeCtx(Subscriber $user = null): PlaceholderContext { $u = $user ?? (function () { - $s = new Subscriber(); - $s->setEmail('user@example.com'); + $s = new Subscriber('user@example.com'); $s->setUniqueId('UID-X'); // Ensure the entity has a non-null ID for repository lookup $rp = new \ReflectionProperty(Subscriber::class, 'id'); diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php index 68818e5e..19caaada 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php @@ -24,8 +24,7 @@ protected function setUp(): void private function makeUser(string $uid = 'U-42'): Subscriber { - $u = new Subscriber(); - $u->setEmail('user@example.com'); + $u = new Subscriber('user@example.com'); $u->setUniqueId($uid); return $u; } diff --git a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php index 00760fa7..1173fac3 100644 --- a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php @@ -59,8 +59,7 @@ public function testSendsToListOwnersWhenFlagEnabled(): void $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 ') + return str_starts_with($data->subject, 'phpList ') && $data->content === 'Hello Admin'; })) ->willReturn(new Email()); @@ -127,7 +126,7 @@ public function testFallsBackToAdminAddressesWhenNoOwnersOrFlagFalse(): void $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 '); + return str_starts_with($data->subject, 'phpList '); })) ->willReturn(new Email()); diff --git a/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php index 74d6bdd0..69510cc0 100644 --- a/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php @@ -33,8 +33,7 @@ public function testNotifyForwardFailedSendsAdminCopyAndLogs(): void $campaign = $this->createMock(Message::class); $campaign->method('getId')->willReturn(42); - $subscriber = new Subscriber(); - $subscriber->setEmail('john@example.com'); + $subscriber = new Subscriber('john@example.com'); $friendEmail = 'friend@example.com'; $lists = [new SubscriberList()]; @@ -105,8 +104,7 @@ public function testNotifyForwardSucceededSendsAdminCopyWithoutLogging(): void $campaign = $this->createMock(Message::class); $campaign->method('getId')->willReturn(777); - $subscriber = new Subscriber(); - $subscriber->setEmail('alice@example.com'); + $subscriber = new Subscriber('alice@example.com'); $friendEmail = 'bob@example.net'; $lists = [new SubscriberList(), new SubscriberList()]; diff --git a/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php index e62dfc22..87334cfe 100644 --- a/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php +++ b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php @@ -181,7 +181,7 @@ public function testGetSubscribersByDefaultReturnsEmptyCollection(): void public function testSetSubscribersSetsSubscribers(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $subscription = new Subscription(); $subscription->setSubscriber($subscriber); diff --git a/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php b/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php index 3d165f66..baffbb73 100644 --- a/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php @@ -65,7 +65,10 @@ public function testThrowsWhenFileDoesNotExist(): void public function testReturnsDownloadableWithExplicitMimeType(): void { $subscriberRepo = $this->createMock(SubscriberRepository::class); - $subscriberRepo->method('findOneByEmail')->with('user@example.com')->willReturn(new Subscriber()); + $subscriberRepo + ->method('findOneByEmail') + ->with('user@example.com') + ->willReturn(new Subscriber('user@example.com')); $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); $filename = 'doc.pdf'; @@ -87,7 +90,10 @@ public function testReturnsDownloadableWithExplicitMimeType(): void public function testGuessesMimeTypeAndProvidesStream(): void { $subscriberRepo = $this->createMock(SubscriberRepository::class); - $subscriberRepo->method('findOneByEmail')->with('user@example.com')->willReturn(new Subscriber()); + $subscriberRepo + ->method('findOneByEmail') + ->with('user@example.com') + ->willReturn(new Subscriber('user@example.com')); $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); $filename = 'note.txt'; diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php index fb4b8740..90af07cb 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -105,13 +105,12 @@ 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); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto, toEmail: ''); $this->assertNull($result); } @@ -119,12 +118,11 @@ 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); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto, toEmail: 'user@example.com'); $this->assertNull($result); } @@ -149,13 +147,12 @@ public function testBlacklistReturnsNullAndMarksHistory(): void ->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); + $result = $builder->buildCampaignEmail(messageId: 5, data: $dto, toEmail: 'user@example.com'); $this->assertNull($result); } @@ -164,8 +161,8 @@ public function testBuildsHtmlPreferredWithAttachments(): void $this->blacklistRepository ->method('isEmailBlacklisted') ->willReturn(false); + $this->subscriberRepository->method('findOneByEmail')->willReturn(new Subscriber('user@example.com')); $dto = new MessagePrecacheDto(); - $dto->to = 'real@example.com'; $dto->subject = 'Subject'; $dto->content = 'TEXT'; $dto->fromEmail = 'from@example.com'; @@ -191,6 +188,7 @@ public function testBuildsHtmlPreferredWithAttachments(): void [$email, $sentAs] = $builder->buildCampaignEmail( messageId: 777, data: $dto, + toEmail: 'real@example.com', skipBlacklistCheck: false, inBlast: true, htmlPref: false, @@ -218,10 +216,9 @@ public function testPrefersTextWhenNoHtmlContent(): void [ConfigOption::AdminAddress, 'admin@example.com'], [ConfigOption::AlwaysSendTextDomains, ''], ]); - + $this->subscriberRepository->method('findOneByEmail')->willReturn(new Subscriber('user@example.com')); $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'; @@ -237,7 +234,12 @@ public function testPrefersTextWhenNoHtmlContent(): void ->willReturn(true); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email, $sentAs] = $builder->buildCampaignEmail(messageId: 9, data: $dto, htmlPref: true); + [$email, $sentAs] = $builder->buildCampaignEmail( + messageId: 9, + data: $dto, + toEmail: 'user@example.com', + htmlPref: true, + ); $this->assertSame(OutputFormat::Text, $sentAs); $this->assertSame('TEXT', $email->getTextBody()); @@ -250,8 +252,8 @@ public function testPdfFormatWhenHtmlPreferred(): void $this->blacklistRepository ->method('isEmailBlacklisted') ->willReturn(false); + $this->subscriberRepository->method('findOneByEmail')->willReturn(new Subscriber('user@example.com')); $dto = new MessagePrecacheDto(); - $dto->to = 'user@example.com'; $dto->subject = 'Subject'; $dto->content = 'TEXT'; $dto->fromEmail = 'from@example.com'; @@ -272,7 +274,12 @@ public function testPdfFormatWhenHtmlPreferred(): void ->willReturn(true); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email, $sentAs] = $builder->buildCampaignEmail(messageId: 42, data: $dto, htmlPref: true); + [$email, $sentAs] = $builder->buildCampaignEmail( + messageId: 42, + data: $dto, + toEmail: 'user@example.com', + htmlPref: true, + ); $this->assertSame(OutputFormat::Pdf, $sentAs); $this->assertCount(1, $email->getAttachments()); @@ -283,8 +290,8 @@ public function testTextAndPdfFormatWhenNotHtmlPreferred(): void $this->blacklistRepository ->method('isEmailBlacklisted') ->willReturn(false); + $this->subscriberRepository->method('findOneByEmail')->willReturn(new Subscriber('user@example.com')); $dto = new MessagePrecacheDto(); - $dto->to = 'user@example.com'; $dto->subject = 'Subject'; $dto->content = 'TEXT'; $dto->fromEmail = 'from@example.com'; @@ -303,7 +310,12 @@ public function testTextAndPdfFormatWhenNotHtmlPreferred(): void ->method('createPdfBytes'); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email, $sentAs] = $builder->buildCampaignEmail(messageId: 43, data: $dto, htmlPref: false); + [$email, $sentAs] = $builder->buildCampaignEmail( + messageId: 43, + data: $dto, + toEmail: 'user@example.com', + htmlPref: false, + ); $this->assertSame(OutputFormat::Text, $sentAs); $this->assertSame('TEXT', $email->getTextBody()); @@ -315,10 +327,10 @@ public function testReplyToExplicitAndTestMailFallback(): void $this->blacklistRepository ->method('isEmailBlacklisted') ->willReturn(false); + $this->subscriberRepository->method('findOneByEmail')->willReturn(new Subscriber('user@example.com')); // explicit reply-to $dto = new MessagePrecacheDto(); - $dto->to = 'user@example.com'; $dto->subject = 'Subject'; $dto->content = 'TEXT'; $dto->fromEmail = 'from@example.com'; @@ -332,12 +344,11 @@ public function testReplyToExplicitAndTestMailFallback(): void ->willReturn(true); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email] = $builder->buildCampaignEmail(messageId: 50, data: $dto); + [$email] = $builder->buildCampaignEmail(messageId: 50, data: $dto, toEmail: 'user@example.com'); $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'; @@ -350,7 +361,12 @@ public function testReplyToExplicitAndTestMailFallback(): void ->with('(test)') ->willReturn('(test)'); - [$email2] = $builder->buildCampaignEmail(messageId: 51, data: $dto2, isTestMail: true); + [$email2] = $builder->buildCampaignEmail( + messageId: 51, + data: $dto2, + toEmail: 'user@example.com', + isTestMail: true, + ); $this->assertSame('admin@example.com', $email2->getReplyTo()[0]->getAddress()); $this->assertStringStartsWith('(test) ', $email2->getSubject()); } @@ -394,8 +410,9 @@ public function testAttachmentAdderFailureThrows(): void $this->blacklistRepository ->method('isEmailBlacklisted') ->willReturn(false); + $this->subscriberRepository->method('findOneByEmail')->willReturn(new Subscriber('user@example.com')); + $dto = new MessagePrecacheDto(); - $dto->to = 'user@example.com'; $dto->subject = 'Subject'; $dto->content = 'TEXT'; $dto->fromEmail = 'from@example.com'; @@ -413,6 +430,6 @@ public function testAttachmentAdderFailureThrows(): void $builder = $this->makeBuilder(devVersion: false, devEmail: null); $this->expectException(AttachmentException::class); - $builder->buildCampaignEmail(messageId: 60, data: $dto, htmlPref: true); + $builder->buildCampaignEmail(messageId: 60, data: $dto, htmlPref: true, toEmail: 'user@example.com'); } } diff --git a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php index ddb0de83..8c1c7c49 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php @@ -147,7 +147,7 @@ public function testBuildsForwardEmailWithSubjectPrefixHeadersAndReplyTo(): void [$email, $sentAs] = $builder->buildForwardEmail( messageId: 99, friendEmail: $friendEmail, - forwardedBy: new Subscriber(), + forwardedBy: new Subscriber('alice@example.com'), data: $dto, htmlPref: true, fromName: $fromName, @@ -186,7 +186,7 @@ public function testReturnsNullWhenEmptySubjectAndLogs(): void $builder->buildForwardEmail( messageId: 1, friendEmail: $friend, - forwardedBy: new Subscriber(), + forwardedBy: new Subscriber('alice@example.com'), data: $dto, htmlPref: false, fromName: 'X', @@ -216,7 +216,7 @@ public function testBlacklistReturnsNullAndMarksHistory(): void $result = $builder->buildForwardEmail( messageId: 2, friendEmail: 'friend@example.com', - forwardedBy: new Subscriber(), + forwardedBy: new Subscriber('alice@example.com'), data: $dto, htmlPref: false, fromName: 'From', diff --git a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php index 849449cb..b003ca32 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php @@ -82,12 +82,11 @@ 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); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto, toEmail: ''); $this->assertNull($result); } @@ -95,11 +94,10 @@ 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); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto, toEmail: 'user@example.com'); $this->assertNull($result); } @@ -123,12 +121,11 @@ public function testReturnsNullWhenBlacklistedAndHistoryUpdated(): void ->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); + $result = $builder->buildCampaignEmail(messageId: 5, data: $dto, toEmail: 'user@example.com'); $this->assertNull($result); } @@ -138,7 +135,6 @@ public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void ->method('isEmailBlacklisted') ->willReturn(false); $dto = new MessagePrecacheDto(); - $dto->to = 'real@example.com'; $dto->subject = 'Subject'; $dto->content = 'TEXT'; @@ -164,6 +160,7 @@ public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void messageId: 777, data: $dto, skipBlacklistCheck: false, + toEmail: 'real@example.com' ); $this->assertNotNull($email); diff --git a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php index 84ace526..71ea8d3f 100644 --- a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php @@ -12,7 +12,6 @@ use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Configuration\Service\MessagePlaceholderProcessor; use PhpList\Core\Domain\Messaging\Exception\RemotePageFetchException; -use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -71,19 +70,6 @@ private function makeBuilder(): CampaignMailContentBuilder ); } - 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) @@ -105,7 +91,6 @@ static function (...$args): string { ); $dto = new MessagePrecacheDto(); - $dto->to = 'user@example.com'; $dto->content = 'Hi'; $dto->htmlFormatted = true; @@ -115,7 +100,7 @@ static function (...$args): string { ->willReturn('Hi'); $builder = $this->makeBuilder(); - [$html, $text] = $builder($dto, 5); + [$html, $text] = $builder($dto, $subscriber, 5); $this->assertSame('Hi', $text); $this->assertStringContainsString('Hi', $html); @@ -148,7 +133,6 @@ static function (...$args): string { ); $dto = new MessagePrecacheDto(); - $dto->to = 'user@example.com'; $dto->content = 'Hello world'; $dto->htmlFormatted = false; @@ -158,7 +142,7 @@ static function (...$args): string { ->willReturn('

Hello world

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

Hello world

', $html); @@ -183,7 +167,6 @@ public function testUserSpecificUrlReplacementAndExceptionOnEmpty(): void // Success path replacement $dto = new MessagePrecacheDto(); - $dto->to = 'user@example.com'; $dto->content = 'Intro [URL:example.com/path] End'; $dto->userSpecificUrl = true; @@ -204,12 +187,11 @@ static function (...$args): string { ); $builder = $this->makeBuilder(); - [$html] = $builder($dto, 11); + [$html] = $builder($dto, $subscriber, 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; @@ -218,7 +200,7 @@ static function (...$args): string { ->method('log'); $this->expectException(RemotePageFetchException::class); - $builder($dto2, 12); + $builder($dto2, $subscriber, 12); } public function testTemplatePreventsDefaultStyleInjection(): void @@ -242,13 +224,12 @@ static function (...$args): string { ); $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); + [$html, $text] = $builder($dto, $subscriber, 2); $this->assertStringContainsString('BEFORE

Inner

AFTER', $html); $this->assertStringNotContainsString( diff --git a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php index 1fa1b037..8a7bc67a 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -42,7 +42,7 @@ public function testThrowsWhenCacheMissing(): void $campaign = $this->createMock(Message::class); $campaign->method('getId')->willReturn(10); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->cache ->expects(self::once()) @@ -76,7 +76,7 @@ public function testProcessesLinksAndDelegatesToBuilder(): void $campaign = $this->createMock(Message::class); $campaign->method('getId')->willReturn(42); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $subscriber->setHtmlEmail(true); $cached = new MessagePrecacheDto(); diff --git a/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php index 5bbdfc0b..ca02a0b6 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php @@ -73,7 +73,7 @@ public function testMarkSentDelegatesToManager(): void ); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $friendEmail = 'friend@example.test'; $this->forwardManager->expects(self::once()) @@ -97,7 +97,7 @@ public function testMarkFailedDelegatesToManager(): void ); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $friendEmail = 'friend@example.test'; $this->forwardManager->expects(self::once()) diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php index b1ee10b9..d58603d6 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php @@ -43,7 +43,7 @@ public function testAssertCanForwardReturnsSubscriber(): void $uid = 'abc'; $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->subscriberRepo->method('findOneByUniqueId')->with($uid)->willReturn($subscriber); $this->userMessageRepo->method('findByUserAndMessage')->willReturn( @@ -81,7 +81,7 @@ public function testAssertCanForwardThrowsWhenMessageNotReceived(): void forwardEmailPeriod: '1 day', ); - $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber('alice@example.com')); $this->userMessageRepo->method('findByUserAndMessage')->willReturn(null); $this->expectException(MessageNotReceivedException::class); @@ -98,7 +98,7 @@ public function testAssertCanForwardThrowsWhenLimitExceeded(): void forwardEmailPeriod: '1 day', ); - $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber('alice@example.com')); $this->userMessageRepo->method('findByUserAndMessage')->willReturn($this->createMock(UserMessage::class)); $this->forwardRepo->method('getCountByUserSince')->willReturn(2); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 9de0df4d..8dabc30e 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -179,7 +179,7 @@ public function testFetchUserMessageBounceBatchDelegates(): void public function testGetUserMessageHistoryWithBouncesDelegates(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $expected = []; $this->userMessageBounceRepository->expects($this->once()) ->method('getUserMessageHistoryWithBounces') diff --git a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php index 6e7c059a..6eeab890 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php @@ -75,7 +75,7 @@ public function testSkipsAlreadySentAndStillUpdatesStats(): void { $service = $this->createService(); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->loader->expects(self::once()) ->method('__invoke') @@ -114,7 +114,7 @@ public function testPrecacheFailureNotifiesAndMarksFailed(): void { $service = $this->createService(); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->loader->method('__invoke')->willReturn(['ok' => true]); $this->guard->method('assertCanForward')->willReturn($subscriber); @@ -142,7 +142,7 @@ public function testSuccessfulFlowSendsAndUpdatesEverything(): void { $service = $this->createService(); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->loader->method('__invoke')->willReturn(['ok' => true]); $this->guard->method('assertCanForward')->willReturn($subscriber); @@ -181,7 +181,7 @@ public function testGetContentsThrowsEmailBlacklistedIsHandledAsFailureAndReport { $service = $this->createService(); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->loader->method('__invoke')->willReturn(['ok' => true]); $this->guard->method('assertCanForward')->willReturn($subscriber); @@ -214,7 +214,7 @@ public function testGetContentsThrowsInvalidRecipientIsHandledAsFailureAndReport { $service = $this->createService(); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->loader->method('__invoke')->willReturn(['ok' => true]); $this->guard->method('assertCanForward')->willReturn($subscriber); @@ -245,7 +245,7 @@ public function testPrecacheFailureAlsoReflectedInForwardingResult(): void { $service = $this->createService(); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->loader->method('__invoke')->willReturn(['data' => true]); $this->guard->method('assertCanForward')->willReturn($subscriber); @@ -272,7 +272,7 @@ public function testMixedScenarioAggregatesResultsAndSideEffects(): void { $service = $this->createService(); $campaign = $this->createMock(Message::class); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('alice@example.com'); $this->loader->method('__invoke')->willReturn(['ok' => 1]); $this->guard->method('assertCanForward')->willReturn($subscriber); diff --git a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php index 0827f7fd..d1aa848c 100644 --- a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php +++ b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php @@ -28,7 +28,7 @@ class SubscriberTest extends TestCase protected function setUp(): void { - $this->subscriber = new Subscriber(); + $this->subscriber = new Subscriber(''); } public function testIsDomainModel(): void diff --git a/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php b/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php index d6abfe09..e0148a83 100644 --- a/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php +++ b/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php @@ -42,7 +42,7 @@ public function testGetSubscriberInitiallyReturnsNull(): void public function testSetSubscriberSetsSubscriber(): void { - $model = new Subscriber(); + $model = new Subscriber('test@example.com'); $this->subject->setSubscriber($model); self::assertSame($model, $this->subject->getSubscriber()); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php index 4d3e7e3a..e0632df9 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php @@ -22,7 +22,7 @@ class SubscriberAttributeManagerTest extends TestCase { public function testCreateNewSubscriberAttribute(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('test@example.com'); $definition = new SubscriberAttributeDefinition(); $subscriberAttrRepo = $this->createMock(SubscriberAttributeValueRepository::class); @@ -53,7 +53,7 @@ public function testCreateNewSubscriberAttribute(): void public function testUpdateExistingSubscriberAttribute(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('test@example.com'); $definition = new SubscriberAttributeDefinition(); $existing = new SubscriberAttributeValue($definition, $subscriber); $existing->setValue('Old'); @@ -83,7 +83,7 @@ public function testUpdateExistingSubscriberAttribute(): void public function testCreateFailsWhenValueAndDefaultAreNull(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('test@example.com'); $definition = new SubscriberAttributeDefinition(); $subscriberAttrRepo = $this->createMock(SubscriberAttributeValueRepository::class); @@ -109,7 +109,10 @@ public function testGetSubscriberAttribute(): void $subscriberAttrRepo = $this->createMock(SubscriberAttributeValueRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $expected = new SubscriberAttributeValue(new SubscriberAttributeDefinition(), new Subscriber()); + $expected = new SubscriberAttributeValue( + new SubscriberAttributeDefinition(), + new Subscriber('test@example.com') + ); $subscriberAttrRepo->expects(self::once()) ->method('findOneBySubscriberIdAndAttributeId') ->with(5, 10) diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php index 16ca73bd..a78bbc0b 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php @@ -130,6 +130,7 @@ public function testRemoveEmailFromBlacklistRemovesAllRelatedData(): void $blacklist = $this->createMock(UserBlacklist::class); $blacklistData = $this->createMock(UserBlacklistData::class); $subscriber = $this->getMockBuilder(Subscriber::class) + ->setConstructorArgs(['test@example.com']) ->onlyMethods(['setBlacklisted']) ->getMock(); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php index edbe1c07..8bcd19d5 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php @@ -44,7 +44,7 @@ protected function setUp(): void public function testCreateSubscriptionWhenSubscriberExists(): void { $email = 'test@example.com'; - $subscriber = new Subscriber(); + $subscriber = new Subscriber($email); $list = new SubscriberList(); $this->subscriberRepository->method('findOneBy')->with(['email' => $email])->willReturn($subscriber); @@ -106,7 +106,7 @@ public function testGetSubscriberListMembersReturnsList(): void { $subscriberList = $this->createMock(SubscriberList::class); $subscriberList->method('getId')->willReturn(1); - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $this->subscriberRepository ->method('getSubscribersBySubscribedListId') diff --git a/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php index c01ae3b1..a083e0a9 100644 --- a/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php +++ b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php @@ -40,7 +40,7 @@ private function createAttribute( private function createUserAttr(SubscriberAttributeDefinition $def, ?string $value): SubscriberAttributeValue { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $userAttr = new SubscriberAttributeValue($def, $subscriber); $userAttr->setValue($value); diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php index a38baabf..1825ebfa 100644 --- a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php @@ -39,7 +39,7 @@ protected function setUp(): void public function testNoChangesWhenNewAndExistingAreIdenticalCaseInsensitive(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $existing = [ $this->attr('Name', 'John', $subscriber), $this->attr('Age', '30', $subscriber), @@ -64,7 +64,7 @@ public function testNoChangesWhenNewAndExistingAreIdenticalCaseInsensitive(): vo public function testAddedAttributeAppearsWithNullOldValue(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $existing = [ $this->attr('Name', 'John', $subscriber), ]; @@ -86,7 +86,7 @@ public function testAddedAttributeAppearsWithNullOldValue(): void public function testRemovedAttributeAppearsWithNullNewValue(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $existing = [ $this->attr('Country', 'US', $subscriber), ]; @@ -102,7 +102,7 @@ public function testRemovedAttributeAppearsWithNullNewValue(): void public function testChangedAttributeShowsOldAndNewValues(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $existing = [ $this->attr('Phone', '123', $subscriber), ]; @@ -121,7 +121,7 @@ public function testChangedAttributeShowsOldAndNewValues(): void public function testIgnoredAttributesAreExcluded(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $existing = [ $this->attr('Password', 'old', $subscriber), $this->attr('Modified', 'yesterday', $subscriber), @@ -147,7 +147,7 @@ public function testIgnoredAttributesAreExcluded(): void public function testCaseInsensitiveKeyComparisonAndResultLowercasing(): void { - $subscriber = new Subscriber(); + $subscriber = new Subscriber('user@example.com'); $existing = [ $this->attr('FirstName', 'Ann', $subscriber), ];