diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 1e195cad..bdeba7c6 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -37,6 +37,8 @@ parameters: env(REST_API_BASE_URL): 'http://api.phplist.local/api/v2' app.frontend_base_url: '%%env(FRONT_END_BASE_URL)%%' env(FRONT_END_BASE_URL): 'http://frontend.phplist.local' + parallel_use_with_phplist3: '%%env(parallel_use_with_phplist3)%%' + env(parallel_use_with_phplist3): '0' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' diff --git a/config/services/services.yml b/config/services/services.yml index 53a5f84d..f8bf5b97 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -43,6 +43,14 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Subscription\Service\SubscribePageConfigMigrationService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator: autowire: true autoconfigure: true diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index 0124af9c..3b22285f 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Repository\ConfigRepository; use Psr\SimpleCache\CacheInterface; +use ValueError; class ConfigProvider { @@ -71,16 +72,32 @@ public function getValue(ConfigOption $key): ?string return $this->defaultConfigs->has($key) ? (string) $this->defaultConfigs->get($key)['value'] : null; } - public function getValueWithNamespace(ConfigOption $key): ?string + public function getValueWithNamespace(ConfigOption|string $key): ?string { - $full = $this->getValue($key); + if ($key instanceof ConfigOption) { + $full = $this->getValue($key); + $keyValue = $key->value; + } else { + $keyValue = $key; + $cacheKey = 'cfg:' . $keyValue; + $full = $this->cache->get($cacheKey); + if ($full === null) { + $full = $this->configRepository->findValueByItem($keyValue); + $this->cache->set($cacheKey, $full, $this->ttlSeconds); + } + } + if ($full !== null && $full !== '') { return $full; } - if (str_contains($key->value, ':')) { - [$parent] = explode(':', $key->value, 2); - $parentKey = ConfigOption::from($parent); + if (str_contains($keyValue, ':')) { + [$parent] = explode(':', $keyValue, 2); + try { + $parentKey = ConfigOption::from($parent); + } catch (ValueError) { + return null; + } return $this->getValue($parentKey); } diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php index 979b3c4c..3b484920 100644 --- a/src/Domain/Subscription/Model/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -30,6 +30,9 @@ class SubscribePage implements DomainModel, Identity, OwnableInterface #[ORM\JoinColumn(name: 'owner', referencedColumnName: 'id', nullable: true)] private ?Administrator $owner = null; + /** @var SubscribePageData[] */ + private array $data = []; + public function getId(): ?int { return $this->id; @@ -50,6 +53,12 @@ public function getOwner(): ?Administrator return $this->owner; } + /** @return SubscribePageData[] */ + public function getData(): array + { + return $this->data; + } + public function setTitle(string $title): self { $this->title = $title; @@ -67,4 +76,11 @@ public function setOwner(?Administrator $owner): self $this->owner = $owner; return $this; } + + /** @param SubscribePageData[] $data */ + public function setData(array $data): self + { + $this->data = $data; + return $this; + } } diff --git a/src/Domain/Subscription/Repository/SubscriberPageRepository.php b/src/Domain/Subscription/Repository/SubscriberPageRepository.php index 136b589c..c9f39449 100644 --- a/src/Domain/Subscription/Repository/SubscriberPageRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberPageRepository.php @@ -4,6 +4,8 @@ namespace PhpList\Core\Domain\Subscription\Repository; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; +use PhpList\Core\Domain\Common\Model\PaginatedResult; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -14,17 +16,92 @@ class SubscriberPageRepository extends AbstractRepository implements Paginatable { use CursorPaginationTrait; - /** @return array{page: SubscribePage, data: SubscribePageData}[] */ - public function findPagesWithData(int $pageId): array + public function findPageWithData(int $pageId): ?SubscribePage { - return $this->createQueryBuilder('p') - ->select('p AS page, d AS data') - ->from(SubscribePage::class, 'p') - ->from(SubscribePageData::class, 'd') + $result = $this->createQueryBuilder('p') + ->select('p AS page', 'd AS data') + ->leftJoin( + SubscribePageData::class, + 'd', + 'ON', + 'd.id = p.id' + ) ->where('p.id = :id') - ->andWhere('d.id = p.id') ->setParameter('id', $pageId) ->getQuery() ->getResult(); + + if ($result === []) { + return null; + } + + return $this->loadPageData($result); + } + + public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedResult + { + $queryBuilder = $this->createQueryBuilder('p'); + + $countQb = clone $queryBuilder; + $total = (int) $countQb + ->select('COUNT(DISTINCT p.id)') + ->getQuery() + ->getSingleScalarResult(); + + $rows = $queryBuilder + ->select('p AS page', 'd AS data') + ->leftJoin( + SubscribePageData::class, + 'd', + 'ON', + 'd.id = p.id' + ) + ->andWhere('p.id > :afterId') + ->setParameter('afterId', $filter->getLastId()) + ->orderBy('p.id', 'ASC') + ->setMaxResults($filter->getLimit()) + ->getQuery() + ->getResult(); + + $grouped = []; + foreach ($rows as $row) { + /** @var SubscribePage $page */ + $page = $row['page'] ?? null; + $data = $row['data'] ?? null; + if ($page !== null) { + $grouped[$page->getId()][] = $row; + } + if ($data !== null) { + $grouped[$data->getId()][] = ['data' => $data]; + } + } + + $pages = []; + foreach ($grouped as $group) { + $pages[] = $this->loadPageData($group); + } + + return new PaginatedResult( + items: array_values($pages), + total: $total, + limit: $filter->getLimit(), + lastId: $filter->getLastId(), + ); + } + + private function loadPageData(array $result): SubscribePage + { + /** @var SubscribePage $page */ + $page = array_shift($result)['page']; + + $data = []; + foreach ($result as $row) { + if (isset($row['data'])) { + $data[] = $row['data']; + } + } + $page->setData($data); + + return $page; } } diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php index 4f9474d5..3a02e4cc 100644 --- a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php @@ -5,24 +5,44 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; +use LogicException; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\Core\Domain\Subscription\Model\SubscribePageData; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -use Symfony\Contracts\Translation\TranslatorInterface; +use PhpList\Core\Domain\Subscription\Service\SubscribePageConfigMigrationService; +use Symfony\Component\DependencyInjection\Attribute\Autowire; class SubscribePageManager { public function __construct( private readonly SubscriberPageRepository $pageRepository, private readonly SubscriberPageDataRepository $pageDataRepository, + private readonly SubscribePageConfigMigrationService $configMigrationService, private readonly EntityManagerInterface $entityManager, - private readonly TranslatorInterface $translator, + #[Autowire('%parallel_use_with_phplist3%')] + private readonly bool $parallelUseWithPhpList3, ) { } + public function findPage(int $id): ?SubscribePage + { + $page = $this->pageRepository->findPageWithData($id); + if ($page === null) { + return null; + } + + if ($this->parallelUseWithPhpList3) { + $changed = $this->configMigrationService->copyToPageData($page); + if ($changed) { + $page = $this->pageRepository->findPageWithData($id) ?? $page; + } + } + + return $page; + } + public function createPage(string $title, bool $active = false, ?Administrator $owner = null): SubscribePage { $page = new SubscribePage(); @@ -35,15 +55,39 @@ public function createPage(string $title, bool $active = false, ?Administrator $ return $page; } - public function getPage(int $id): SubscribePage + public function syncPageData(array $data, SubscribePage $page): void { - /** @var SubscribePage|null $page */ - $page = $this->pageRepository->find($id); - if (!$page) { - throw new NotFoundHttpException($this->translator->trans('Subscribe page not found')); + if ($page->getId() === null) { + throw new LogicException('Page must be persisted before syncing data'); + } + $existingPageData = []; + foreach ($this->getPageData($page) as $pageData) { + $existingPageData[$pageData->getName()] = $pageData; } - return $page; + foreach ($data as $pageDataKey => $value) { + if (isset($existingPageData[$pageDataKey])) { + $pageData = $existingPageData[$pageDataKey]; + $pageData->setData($value); + unset($existingPageData[$pageDataKey]); + continue; + } + + $pageData = (new SubscribePageData()) + ->setId($page->getId()) + ->setName($pageDataKey) + ->setData($value); + + $this->pageDataRepository->persist($pageData); + } + + foreach ($existingPageData as $pageData) { + $this->entityManager->remove($pageData); + } + + if ($this->parallelUseWithPhpList3) { + $this->configMigrationService->copyToConfig(page: $page, data: $data); + } } public function updatePage( @@ -76,25 +120,8 @@ public function deletePage(SubscribePage $page): void } /** @return SubscribePageData[] */ - public function getPageData(SubscribePage $page): array + private function getPageData(SubscribePage $page): array { - return $this->pageDataRepository->getByPage($page,); - } - - public function setPageData(SubscribePage $page, string $name, ?string $value): SubscribePageData - { - /** @var SubscribePageData|null $data */ - $data = $this->pageDataRepository->findByPageAndName($page, $name); - - if (!$data) { - $data = (new SubscribePageData()) - ->setId((int)$page->getId()) - ->setName($name); - $this->entityManager->persist($data); - } - - $data->setData($value); - - return $data; + return $this->pageDataRepository->getByPage($page); } } diff --git a/src/Domain/Subscription/Service/SubscribePageConfigMigrationService.php b/src/Domain/Subscription/Service/SubscribePageConfigMigrationService.php new file mode 100644 index 00000000..6c0f3441 --- /dev/null +++ b/src/Domain/Subscription/Service/SubscribePageConfigMigrationService.php @@ -0,0 +1,165 @@ +getId(); + if ($pageId === null) { + return false; + } + + $configValues = $this->getConfigValues($pageId); + if ($configValues === []) { + return false; + } + + $existingData = $this->pageDataRepository->getByPage($page); + $existingDataByName = $this->indexPageDataByName($existingData); + + $hasChanges = $this->syncPageData( + $pageId, + $configValues, + $existingData, + $existingDataByName + ); + + $page->setData($existingData); + + if ($hasChanges) { + $this->entityManager->flush(); + } + + return $hasChanges; + } + + private function getConfigValues(int $pageId): array + { + $configValues = []; + + foreach (self::SUBSCRIBE_PAGE_SUFFIXES as $suffix) { + $value = $this->configRepository->findValueByItem($suffix . ':' . $pageId); + + if ($value !== null) { + $configValues[$suffix] = $value; + } + } + + return $configValues; + } + + private function indexPageDataByName(array $existingData): array + { + $indexed = []; + + foreach ($existingData as $pageData) { + $indexed[$pageData->getName()] = $pageData; + } + + return $indexed; + } + + private function syncPageData( + int $pageId, + array $configValues, + array &$existingData, + array $existingDataByName + ): bool { + $hasChanges = false; + + foreach ($configValues as $name => $value) { + if (isset($existingDataByName[$name])) { + $hasChanges = $this->updateExistingPageData( + $existingDataByName[$name], + $value + ) || $hasChanges; + + continue; + } + + $newPageData = (new SubscribePageData()) + ->setId($pageId) + ->setName($name) + ->setData($value); + + $this->pageDataRepository->persist($newPageData); + + $existingData[] = $newPageData; + $hasChanges = true; + } + + return $hasChanges; + } + + private function updateExistingPageData( + SubscribePageData $pageData, + string $value + ): bool { + if ($pageData->getData() === $value) { + return false; + } + + $pageData->setData($value); + + return true; + } + + public function copyToConfig(SubscribePage $page, array $data): void + { + $pageId = $page->getId(); + if ($pageId === null) { + return; + } + + foreach (self::SUBSCRIBE_PAGE_SUFFIXES as $suffix) { + if (!array_key_exists($suffix, $data)) { + continue; + } + + $value = $data[$suffix]; + if (!is_string($value) && $value !== null) { + continue; + } + + $configKey = $suffix . ':' . $pageId; + $config = $this->configRepository->findOneBy(['key' => $configKey]); + if (!$config instanceof Config) { + $config = (new Config()) + ->setKey($configKey) + ->setValue($value); + + $this->configRepository->persist($config); + continue; + } + + $config->setValue($value); + } + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php index c5744908..ab6e90c5 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php @@ -51,29 +51,6 @@ private function pickNonBooleanCase(): ConfigOption $this->markTestSkipped('No non-boolean ConfigOption cases available to test.'); } - /** - * Utility: pick a namespaced case "parent:child" where parent exists as its own case. - */ - private function pickNamespacedCasePair(): array - { - $byValue = []; - foreach (ConfigOption::cases() as $c) { - $byValue[$c->value] = $c; - } - - foreach (ConfigOption::cases() as $c) { - if (!str_contains($c->value, ':')) { - continue; - } - [$parent] = explode(':', $c->value, 2); - if (isset($byValue[$parent])) { - return [$c, $byValue[$parent]]; - } - } - - $this->markTestSkipped('No namespaced ConfigOption (parent:child) pair found.'); - } - public function testIsEnabledRejectsNonBooleanKeys(): void { $nonBoolean = $this->pickNonBooleanCase(); @@ -248,32 +225,27 @@ public function testGetValueWithNamespacePrefersFullValue(): void $this->assertSame('FULL', $this->provider->getValueWithNamespace($key)); } - public function testGetValueWithNamespaceFallsBackToParentWhenFullEmpty(): void + public function testGetValueWithNamespaceSupportsStringNamespacedKey(): void { - [$child, $parent] = $this->pickNamespacedCasePair(); - - // Simulate: child is empty (null or ''), parent has value "PARENTVAL" $this->cache ->method('get') ->willReturnMap([ - ['cfg:' . $child->value, null], - ['cfg:' . $parent->value, 'PARENTVAL'], + ['cfg:subscribemessage:1', null], + ['cfg:subscribemessage', 'PARENT'], ]); - // child -> repo null; parent -> not consulted because cache returns value $this->repo ->method('findValueByItem') ->willReturnMap([ - [$child->value, null], + ['subscribemessage:1', null], + ['subscribemessage', 'PARENT'], ]); - // child miss is cached as null, parent value is not rewritten here (already cached) $this->cache ->expects($this->atLeastOnce()) - ->method('set'); - - $this->defaults->method('has')->willReturn(false); + ->method('set') + ->withAnyParameters(); - $this->assertSame('PARENTVAL', $this->provider->getValueWithNamespace($child)); + $this->assertSame('PARENT', $this->provider->getValueWithNamespace('subscribemessage:1')); } } diff --git a/tests/Unit/Domain/Identity/Repository/AdminAttributeValueRepositoryTest.php b/tests/Unit/Domain/Identity/Repository/AdminAttributeValueRepositoryTest.php index 99695df1..7f74c7b9 100644 --- a/tests/Unit/Domain/Identity/Repository/AdminAttributeValueRepositoryTest.php +++ b/tests/Unit/Domain/Identity/Repository/AdminAttributeValueRepositoryTest.php @@ -54,7 +54,11 @@ public function testFindOneByAdminIdAndAttributeId(): void $administrator = $this->createMock(Administrator::class); $administrator->method('getId')->willReturn($adminId); - $expectedResult = new AdminAttributeValue($attributeDefinition, $administrator, 'value'); + $expectedResult = new AdminAttributeValue( + $attributeDefinition, + $administrator, + 'value' + ); $this->entityManager->expects($this->once()) ->method('createQueryBuilder') @@ -70,13 +74,17 @@ public function testFindOneByAdminIdAndAttributeId(): void ->with(AdminAttributeValue::class, 'aav') ->willReturn($this->queryBuilder); + $joinCalls = []; + $this->queryBuilder->expects($this->exactly(2)) ->method('join') - ->withConsecutive( - ['aav.administrator', 'admin'], - ['aav.attributeDefinition', 'attr'] - ) - ->willReturn($this->queryBuilder); + ->willReturnCallback( + function (string $join, string $alias) use (&$joinCalls) { + $joinCalls[] = [$join, $alias]; + + return $this->queryBuilder; + } + ); $this->queryBuilder->expects($this->once()) ->method('where') @@ -88,13 +96,17 @@ public function testFindOneByAdminIdAndAttributeId(): void ->with('attr.id = :attributeId') ->willReturn($this->queryBuilder); + $setParameterCalls = []; + $this->queryBuilder->expects($this->exactly(2)) ->method('setParameter') - ->withConsecutive( - ['adminId', $adminId], - ['attributeId', $attributeId] - ) - ->willReturn($this->queryBuilder); + ->willReturnCallback( + function (string $key, mixed $value) use (&$setParameterCalls) { + $setParameterCalls[] = [$key, $value]; + + return $this->queryBuilder; + } + ); $this->queryBuilder->expects($this->once()) ->method('getQuery') @@ -104,7 +116,26 @@ public function testFindOneByAdminIdAndAttributeId(): void ->method('getOneOrNullResult') ->willReturn($expectedResult); - $result = $this->subject->findOneByAdminIdAndAttributeId($adminId, $attributeId); + $result = $this->subject->findOneByAdminIdAndAttributeId( + $adminId, + $attributeId + ); + + $this->assertSame( + [ + ['aav.administrator', 'admin'], + ['aav.attributeDefinition', 'attr'], + ], + $joinCalls, + ); + + $this->assertSame( + [ + ['adminId', $adminId], + ['attributeId', $attributeId], + ], + $setParameterCalls, + ); $this->assertSame($expectedResult, $result); } diff --git a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php index 1173fac3..6f2e4cb4 100644 --- a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php @@ -108,44 +108,61 @@ public function testSendsToListOwnersWhenFlagEnabled(): void public function testFallsBackToAdminAddressesWhenNoOwnersOrFlagFalse(): void { $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('isEnabled') ->with(ConfigOption::SendAdminCopies) ->willReturn(true); + $getValueCalls = []; + $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 ' + ->willReturnCallback( + function (ConfigOption $key) use (&$getValueCalls): string { + $getValueCalls[] = $key; + + return match ($key) { + ConfigOption::AdminAddress => 'single@example.com', + ConfigOption::AdminAddresses => + ' admin1@example.com, , admin2@example.com ,admin1@example.com ', + default => '', + }; + } ); - $expectedRecipients = ['admin1@example.com', 'admin2@example.com', 'single@example.com']; + $expectedRecipients = [ + 'admin1@example.com', + 'admin2@example.com', + 'single@example.com', + ]; $systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); - $systemEmailBuilder->expects(self::exactly(count($expectedRecipients))) + + $buildCalls = []; + + $systemEmailBuilder + ->expects(self::exactly(count($expectedRecipients))) ->method('buildSystemEmail') - ->with(self::callback(function (MessagePrecacheDto $data): bool { - return str_starts_with($data->subject, 'phpList '); - })) - ->willReturn(new Email()); + ->willReturnCallback( + function (MessagePrecacheDto $data) use (&$buildCalls): Email { + $buildCalls[] = $data; + + return new Email(); + } + ); $mailer = $this->createMock(MailerInterface::class); + + $sendCalls = []; + $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; - }) + ->willReturnCallback( + function (Email $email, Envelope $envelope) use (&$sendCalls): void { + $sendCalls[] = [$email, $envelope]; + } ); $sender = new AdminCopyEmailSender( @@ -153,14 +170,46 @@ public function testFallsBackToAdminAddressesWhenNoOwnersOrFlagFalse(): void 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]); + + $this->assertSame( + [ + ConfigOption::AdminAddress, + ConfigOption::AdminAddresses, + ], + $getValueCalls, + ); + + $this->assertCount(count($expectedRecipients), $buildCalls); + + foreach ($buildCalls as $call) { + $this->assertInstanceOf(MessagePrecacheDto::class, $call); + $this->assertStringStartsWith('phpList ', $call->subject); + } + + $this->assertCount(count($expectedRecipients), $sendCalls); + + foreach ($sendCalls as $index => [$email, $envelope]) { + $this->assertInstanceOf(Email::class, $email); + + $senderAddress = $envelope->getSender(); + $recipient = $envelope->getRecipients()[0] ?? null; + + $this->assertNotNull($senderAddress); + $this->assertSame($bounce, $senderAddress->getAddress()); + + $this->assertNotNull($recipient); + $this->assertSame( + $expectedRecipients[$index], + $recipient->getAddress() + ); + } } private function createListWithOwner(string $email): SubscriberList diff --git a/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php index 69510cc0..6b14e640 100644 --- a/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php @@ -39,6 +39,7 @@ public function testNotifyForwardFailedSendsAdminCopyAndLogs(): void $lists = [new SubscriberList()]; $expectedSubject = 'Message Forwarded'; + $expectedMessage = sprintf( '%s tried forwarding message %d to %s but failed', $subscriber->getEmail(), @@ -46,27 +47,30 @@ public function testNotifyForwardFailedSendsAdminCopyAndLogs(): void $friendEmail ); - // Translator expectations: first for subject, then for message with placeholders + $translatorCalls = []; + $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 + ->willReturnCallback( + function ( + string $message, + array $params = [] + ) use ( + &$translatorCalls, + $expectedSubject, + $expectedMessage + ): string { + $translatorCalls[] = [$message, $params]; + + return match ($message) { + 'Message Forwarded' => $expectedSubject, + '%subscriber% tried forwarding message %campaignId% to %email% but failed' => $expectedMessage, + default => 'Unknown status encountered.', + }; + } ); - // Admin copy sender should be invoked with translated subject and message and same lists $this->adminCopyEmailSender ->expects(self::once()) ->method('__invoke') @@ -76,7 +80,6 @@ public function testNotifyForwardFailedSendsAdminCopyAndLogs(): void $this->identicalTo($lists) ); - // EventLogManager should log only on failure $this->eventLogManager ->expects(self::once()) ->method('log') @@ -97,8 +100,25 @@ public function testNotifyForwardFailedSendsAdminCopyAndLogs(): void friendEmail: $friendEmail, lists: $lists ); - } + $this->assertSame( + [ + [ + 'Message Forwarded', + [], + ], + [ + '%subscriber% tried forwarding message %campaignId% to %email% but failed', + [ + '%subscriber%' => $subscriber->getEmail(), + '%campaignId%' => 42, + '%email%' => $friendEmail, + ], + ], + ], + $translatorCalls, + ); + } public function testNotifyForwardSucceededSendsAdminCopyWithoutLogging(): void { $campaign = $this->createMock(Message::class); @@ -110,6 +130,7 @@ public function testNotifyForwardSucceededSendsAdminCopyWithoutLogging(): void $lists = [new SubscriberList(), new SubscriberList()]; $expectedSubject = 'Message Forwarded'; + $expectedMessage = sprintf( '%s has forwarded message %d to %s', $subscriber->getEmail(), @@ -117,23 +138,28 @@ public function testNotifyForwardSucceededSendsAdminCopyWithoutLogging(): void $friendEmail ); + $translatorCalls = []; + $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 + ->willReturnCallback( + function ( + string $message, + array $params = [] + ) use ( + &$translatorCalls, + $expectedSubject, + $expectedMessage + ): string { + $translatorCalls[] = [$message, $params]; + + return match ($message) { + 'Message Forwarded' => $expectedSubject, + '%subscriber% has forwarded message %campaignId% to %email%' => $expectedMessage, + default => 'Unknown status encountered.', + }; + } ); $this->adminCopyEmailSender @@ -161,5 +187,23 @@ public function testNotifyForwardSucceededSendsAdminCopyWithoutLogging(): void friendEmail: $friendEmail, lists: $lists ); + + $this->assertSame( + [ + [ + 'Message Forwarded', + [], + ], + [ + '%subscriber% has forwarded message %campaignId% to %email%', + [ + '%subscriber%' => $subscriber->getEmail(), + '%campaignId%' => 777, + '%email%' => $friendEmail, + ], + ], + ], + $translatorCalls, + ); } } diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index fd7aae53..c97547ff 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -203,18 +203,53 @@ public function testUpdatePasswordWithTokenUpdatesPasswordAndRemovesToken(): voi public function testCleanupExpiredTokensRemovesExpiredTokens(): void { $administrator = new Administrator(); - $expiredRequest1 = new AdminPasswordRequest(new DateTime('-1 day'), $administrator, 'token1'); - $expiredRequest2 = new AdminPasswordRequest(new DateTime('-2 days'), $administrator, 'token2'); - $validRequest = new AdminPasswordRequest(new DateTime('+1 day'), $administrator, 'token3'); - $this->passwordRequestRepository->expects($this->once()) + $expiredRequest1 = new AdminPasswordRequest( + new DateTime('-1 day'), + $administrator, + 'token1' + ); + + $expiredRequest2 = new AdminPasswordRequest( + new DateTime('-2 days'), + $administrator, + 'token2' + ); + + $validRequest = new AdminPasswordRequest( + new DateTime('+1 day'), + $administrator, + 'token3' + ); + + $this->passwordRequestRepository + ->expects($this->once()) ->method('findAll') - ->willReturn([$expiredRequest1, $expiredRequest2, $validRequest]); + ->willReturn([ + $expiredRequest1, + $expiredRequest2, + $validRequest, + ]); - $this->passwordRequestRepository->expects($this->exactly(2)) + $removedRequests = []; + + $this->passwordRequestRepository + ->expects($this->exactly(2)) ->method('remove') - ->withConsecutive([$expiredRequest1], [$expiredRequest2]); + ->willReturnCallback( + function (AdminPasswordRequest $request) use (&$removedRequests): void { + $removedRequests[] = $request; + } + ); $this->subject->cleanupExpiredTokens(); + + $this->assertSame( + [ + $expiredRequest1, + $expiredRequest2, + ], + $removedRequests, + ); } } diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index e655f4a5..ee1d0978 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -18,37 +18,69 @@ class SessionManagerTest extends TestCase public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void { $adminRepo = $this->createMock(AdministratorRepository::class); + $adminRepo->expects(self::once()) ->method('findOneByLoginCredentials') ->with('admin', 'wrong') ->willReturn(null); $tokenRepo = $this->createMock(AdministratorTokenRepository::class); - $tokenRepo->expects(self::never())->method('save'); + + $tokenRepo->expects(self::never()) + ->method('save'); $eventLogManager = $this->createMock(EventLogManager::class); + $eventLogManager->expects(self::once()) ->method('log') ->with('login', $this->stringContains('admin')); + $translatorCalls = []; + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::exactly(2)) ->method('trans') - ->withConsecutive( - ["Failed admin login attempt for '%login%'", ['login' => 'admin']], - ['Not authorized', []] - ) - ->willReturnOnConsecutiveCalls( - "Failed admin login attempt for 'admin'", - 'Not authorized' + ->willReturnCallback( + function (string $message, array $parameters = []) use (&$translatorCalls): string { + $translatorCalls[] = [$message, $parameters]; + + return match ($message) { + "Failed admin login attempt for '%login%'" => + "Failed admin login attempt for 'admin'", + 'Not authorized' => 'Not authorized', + default => 'Unknown status encountered.', + }; + } ); - $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator); + $manager = new SessionManager( + $tokenRepo, + $adminRepo, + $eventLogManager, + $translator + ); $this->expectException(UnauthorizedHttpException::class); $this->expectExceptionMessage('Not authorized'); - $manager->createSession('admin', 'wrong'); + try { + $manager->createSession('admin', 'wrong'); + } finally { + $this->assertSame( + [ + [ + "Failed admin login attempt for '%login%'", + ['login' => 'admin'], + ], + [ + 'Not authorized', + [], + ], + ], + $translatorCalls, + ); + } } public function testDeleteSessionCallsRemove(): void diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 0d05bd7a..683c215a 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -333,26 +333,34 @@ public function testInvokeWithMailerException(): void public function testInvokeWithMultipleSubscribers(): void { $campaign = $this->createCampaignMock(); + $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); + $data = new CampaignProcessorMessage(1); - $this->messageRepository->method('findByIdAndStatus') + $this->messageRepository + ->method('findByIdAndStatus') ->with(1, MessageStatus::Submitted) ->willReturn($campaign); - $this->precacheService->expects($this->once()) + $this->precacheService + ->expects($this->once()) ->method('precacheMessage') ->with($campaign, $this->anything()) ->willReturn(true); - $this->cache->method('get')->willReturn($precached); + $this->cache + ->method('get') + ->willReturn($precached); $subscriber1 = $this->createMock(Subscriber::class); $subscriber1->method('getEmail')->willReturn('test1@example.com'); @@ -366,47 +374,94 @@ public function testInvokeWithMultipleSubscribers(): void $subscriber3->method('getEmail')->willReturn('invalid-email'); $subscriber3->method('getId')->willReturn(3); - $this->subscriberProvider->expects($this->once()) + $this->subscriberProvider + ->expects($this->once()) ->method('getSubscribersForMessageOrLists') ->with($data, $campaign) - ->willReturn([$subscriber1, $subscriber2, $subscriber3]); + ->willReturn([ + $subscriber1, + $subscriber2, + $subscriber3, + ]); - $this->messagePreparator->expects($this->exactly(2)) + $processMessageLinksCalls = []; + + $this->messagePreparator + ->expects($this->exactly(2)) ->method('processMessageLinks') - ->withConsecutive( - [1, $precached, $subscriber1], - [1, $precached, $subscriber2] - ) - ->willReturnOnConsecutiveCalls($precached, $precached); + ->willReturnCallback( + function ( + int $campaignId, + MessagePrecacheDto $dto, + Subscriber $subscriber + ) use ( + &$processMessageLinksCalls, + $precached + ): MessagePrecacheDto { + $processMessageLinksCalls[] = [ + $campaignId, + $dto, + $subscriber, + ]; + + return $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)) + + $buildCampaignEmailCalls = []; + + $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 - ], + ->willReturnCallback( + function () use (&$buildCampaignEmailCalls): array { + $buildCampaignEmailCalls[] = func_get_args(); + + static $emails = [ + 'test1@example.com', + 'test2@example.com', + ]; + + $email = array_shift($emails); + + return [ + (new Email()) + ->to($email) + ->subject('Test Subject') + ->text('x'), + OutputFormat::Text, + ]; + } ); - $this->mailer->expects($this->exactly(2)) + $this->mailer + ->expects($this->exactly(2)) ->method('send'); $metadata->expects($this->atLeastOnce()) ->method('setStatus'); - $this->entityManager->expects($this->atLeastOnce()) + $this->entityManager + ->expects($this->atLeastOnce()) ->method('flush'); ($this->handler)($data); + + $this->assertSame( + [ + [1, $precached, $subscriber1], + [1, $precached, $subscriber2], + ], + $processMessageLinksCalls, + ); + + $this->assertCount(2, $buildCampaignEmailCalls); } /** diff --git a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php index 71ea8d3f..df8afd18 100644 --- a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php @@ -155,29 +155,36 @@ public function testUserSpecificUrlReplacementAndExceptionOnEmpty(): void ->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->content = 'Intro [URL:example.com/path] End'; - $dto->userSpecificUrl = true; + $remotePageFetcherCalls = []; $this->remotePageFetcher ->expects($this->exactly(2)) ->method('__invoke') - ->withConsecutive( - ['https://example.com/path', ['id' => 55]], - ['https://example.com/empty', ['id' => 55]], - ) - ->willReturnOnConsecutiveCalls('