createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); $mailjet->expects(self::never())->method('ensureSubscribed'); $consents->expects(self::once())->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); $consents->expects(self::never())->method('findOneByEmailAndListId'); $repository->expects(self::once())->method('deleteExpiredPendingByEmail')->with('customer@example.com')->willReturn(0); $repository->expects(self::once())->method('findPendingByEmail')->with('customer@example.com')->willReturn(null); $entityManager ->expects(self::once()) ->method('persist') ->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool { self::assertSame('customer@example.com', $confirmation->getEmail()); self::assertSame([1, 2], $confirmation->getMailjetListIds()); self::assertSame('Mia', $confirmation->getFirstName()); self::assertSame('Muster', $confirmation->getLastName()); return true; })); $entityManager->expects(self::once())->method('flush'); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::callback(static function (array $context): bool { self::assertArrayHasKey('token', $context); self::assertSame([ ['id' => 1, 'label' => 'List One'], ['id' => 2, 'label' => 'List Two'], ], $context['newsletterLists']); return true; }), self::anything(), ); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestApiSubscription(' Customer@Example.COM ', [2, 1, 2], [1, 2], ' Mia ', ' Muster '); self::assertSame('customer@example.com', $result->email); self::assertSame([1, 2], $result->listIds); self::assertSame(NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, $result->state); self::assertTrue($result->confirmationRequested); self::assertSame([ 1 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, 2 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, ], $result->listStates); } public function testApiRequestDoesNotResendForPendingConfirmationWithSameListSet(): void { $pending = new NewsletterOptInRequest('customer@example.com', str_repeat('a', 64), new \DateTimeImmutable('+1 hour'), [1, 2]); $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); $mailjet->expects(self::never())->method('ensureSubscribed'); $consents->expects(self::once())->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); $consents->expects(self::never())->method('findOneByEmailAndListId'); $repository->method('deleteExpiredPendingByEmail')->willReturn(0); $repository->expects(self::once())->method('findPendingByEmail')->with('customer@example.com')->willReturn($pending); $entityManager->expects(self::never())->method('persist'); $entityManager->expects(self::never())->method('flush'); $mailer->expects(self::never())->method('createAndSendEmail'); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestApiSubscription('customer@example.com', [2, 1]); self::assertSame(NewsletterSubscriptionRequestResult::STATE_PENDING_CONFIRMATION, $result->state); self::assertFalse($result->confirmationRequested); self::assertSame([ 1 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, 2 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, ], $result->listStates); } public function testApiRequestRefreshesPendingConfirmationWithLatestListSetAndNames(): void { $pending = new NewsletterOptInRequest('customer@example.com', str_repeat('a', 64), new \DateTimeImmutable('+1 hour'), [1], 'Old', 'Name'); $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); $mailjet->expects(self::never())->method('ensureSubscribed'); $consents->expects(self::once())->method('findActiveByEmail')->with('customer@example.com')->willReturn(null); $consents->expects(self::never())->method('findOneByEmailAndListId'); $repository->method('deleteExpiredPendingByEmail')->willReturn(0); $repository->expects(self::exactly(2))->method('findPendingByEmail')->with('customer@example.com')->willReturn($pending); $entityManager->expects(self::never())->method('persist'); $entityManager->expects(self::once())->method('flush'); $mailer->expects(self::once())->method('createAndSendEmail'); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestApiSubscription('customer@example.com', [3, 2], null, 'New', 'Person'); self::assertSame(NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, $result->state); self::assertTrue($result->confirmationRequested); self::assertSame([2, 3], $pending->getMailjetListIds()); self::assertSame('New', $pending->getFirstName()); self::assertSame('Person', $pending->getLastName()); } public function testApiRequestUsesLocalConsentStateForAlreadyRegisteredLists(): void { $existingConsent = new NewsletterConsent('customer@example.com', 1, 'Old', 'Name'); $existingConsent->markConfirmed(); $existingOtherConsent = new NewsletterConsent('customer@example.com', 2, 'Old', 'Name'); $existingOtherConsent->markConfirmed(); $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); $mailjet ->expects(self::exactly(2)) ->method('ensureSubscribed') ->withConsecutive(['customer@example.com', 1], ['customer@example.com', 2]); $consents->method('findActiveByEmail')->with('customer@example.com')->willReturn($existingConsent); $consents ->method('findOneByEmailAndListId') ->willReturnCallback(static function (string $email, int $mailjetListId) use ($existingConsent, $existingOtherConsent): ?NewsletterConsent { return match ($mailjetListId) { 1 => $existingConsent, 2 => $existingOtherConsent, default => null, }; }); $consents ->method('findByEmailAndListIds') ->with('customer@example.com', [1, 2]) ->willReturn([1 => $existingConsent, 2 => $existingOtherConsent]); $repository->expects(self::once())->method('deletePendingByEmail')->with('customer@example.com')->willReturn(0); $entityManager->expects(self::never())->method('persist'); $entityManager->expects(self::once())->method('flush'); $mailer->expects(self::never())->method('createAndSendEmail'); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestApiSubscription('customer@example.com', [2, 1], null, 'Mia', 'Muster'); self::assertSame(NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, $result->state); self::assertFalse($result->confirmationRequested); self::assertSame([ 1 => NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED, 2 => NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED, ], $result->listStates); } public function testApiRequestRejectsUnknownListIdsBeforeSideEffects(): void { $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); $mailjet->expects(self::never())->method('ensureSubscribed'); $consents->expects(self::never())->method('findActiveByEmail'); $repository->expects(self::never())->method('findPendingByEmail'); $entityManager->expects(self::never())->method('persist'); $entityManager->expects(self::never())->method('flush'); try { $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestApiSubscription('customer@example.com', [2, 3], [1, 2]); self::fail('Expected unknown Mailjet list IDs to be rejected.'); } catch (NewsletterListNotAllowedException $exception) { self::assertSame('One or more Mailjet list IDs are not allowed.', $exception->getMessage()); self::assertSame([2, 3], $exception->getListIds()); self::assertSame([3], $exception->getUnknownListIds()); } } public function testApiRequestDirectlySubscribesMissingListWhenEmailIsSubscribedToKnownList(): void { $existingConsent = new NewsletterConsent('customer@example.com', 1, 'Old', 'Name'); $existingConsent->markConfirmed(); $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); $mailjet->expects(self::once())->method('ensureSubscribed')->with('customer@example.com', 2); $consents->method('findActiveByEmail')->with('customer@example.com')->willReturn($existingConsent); $consents->method('findOneByEmailAndListId')->willReturn(null); $consents->method('findByEmailAndListIds')->willReturn([]); $repository->expects(self::once())->method('deletePendingByEmail')->with('customer@example.com')->willReturn(0); $repository->expects(self::never())->method('findPendingByEmail'); $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); $entityManager->expects(self::once())->method('flush'); $mailer->expects(self::never())->method('createAndSendEmail'); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestApiSubscription('customer@example.com', [2], [1, 2], 'Mia', 'Muster'); self::assertSame(NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, $result->state); self::assertFalse($result->confirmationRequested); self::assertSame([ 2 => NewsletterSubscriptionRequestResult::LIST_STATE_SUCCESS, ], $result->listStates); } public function testApiRequestDirectlySubscribesMissingListWhenEmailHasLocalConsent(): void { $existingConsent = new NewsletterConsent('customer@example.com', 1, 'Old', 'Name'); $existingConsent->markConfirmed(); $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); $mailjet->expects(self::once())->method('ensureSubscribed')->with('customer@example.com', 2); $consents->method('findActiveByEmail')->with('customer@example.com')->willReturn($existingConsent); $consents->method('findOneByEmailAndListId')->with('customer@example.com', 2)->willReturn(null); $consents->method('findByEmailAndListIds')->willReturn([]); $repository->expects(self::once())->method('deletePendingByEmail')->with('customer@example.com')->willReturn(0); $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); $entityManager->expects(self::once())->method('flush'); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestApiSubscription('customer@example.com', [2], [1, 2], 'Mia', 'Muster'); self::assertSame(NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED, $result->state); self::assertFalse($result->confirmationRequested); } public function testDefaultRequestCreatesDefaultListPendingConfirmation(): void { $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('ensureSubscribed'); $repository->method('deleteExpiredPendingByEmail')->willReturn(0); $repository->method('findPendingByEmail')->with('customer@example.com')->willReturn(null); $entityManager ->expects(self::once()) ->method('persist') ->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool { self::assertSame('customer@example.com', $confirmation->getEmail()); self::assertSame([10321569], $confirmation->getMailjetListIds()); return true; })); $entityManager->expects(self::once())->method('flush'); $mailer->expects(self::once())->method('createAndSendEmail'); $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestConfirmation('customer@example.com'); } public function testRequestConfirmationTruncatesNamesBeforePersisting(): void { $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('ensureSubscribed'); $repository->method('deleteExpiredPendingByEmail')->willReturn(0); $repository->method('findPendingByEmail')->with('customer@example.com')->willReturn(null); $entityManager ->expects(self::once()) ->method('persist') ->with(self::callback(static function (NewsletterOptInRequest $confirmation): bool { self::assertSame(str_repeat('A', 255), $confirmation->getFirstName()); self::assertSame(str_repeat('B', 255), $confirmation->getLastName()); return true; })); $entityManager->expects(self::once())->method('flush'); $mailer->expects(self::once())->method('createAndSendEmail'); $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->requestConfirmation('customer@example.com', str_repeat('A', 300), str_repeat('B', 300)); } public function testConfirmationSubscribesDefaultListAndStoresConsent(): void { $token = 'token'; $confirmation = new NewsletterOptInRequest( 'customer@example.com', hash('sha256', $token), new \DateTimeImmutable('+1 hour'), [], 'Mia', 'Muster', ); $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation); $repository->expects(self::never())->method('deletePendingByEmail'); $consents->expects(self::once())->method('findByEmailAndListIds')->with('customer@example.com', [10321569])->willReturn([]); $mailjet->expects(self::once())->method('upsertContact')->with('customer@example.com', 'Mia', 'Muster'); $mailjet->expects(self::once())->method('ensureSubscribed')->with('customer@example.com', 10321569); $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); $entityManager->expects(self::once())->method('remove')->with($confirmation); $entityManager->expects(self::once())->method('flush'); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->confirmToken($token); self::assertSame(NewsletterConfirmationResult::STATUS_CONFIRMED, $result->status); self::assertSame([10321569], $confirmation->getMailjetListIds()); } public function testConfirmationSubscribesStoredListIdsAndStoresConsents(): void { $token = 'token'; $confirmation = new NewsletterOptInRequest( 'customer@example.com', hash('sha256', $token), new \DateTimeImmutable('+1 hour'), [2, 1], ); $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation); $repository->expects(self::never())->method('deletePendingByEmail'); $consents->method('findOneByEmailAndListId')->willReturn(null); $consents->method('findByEmailAndListIds')->with('customer@example.com', [1, 2])->willReturn([]); $mailjet ->expects(self::exactly(2)) ->method('ensureSubscribed') ->withConsecutive(['customer@example.com', 1], ['customer@example.com', 2]); $entityManager->expects(self::exactly(2))->method('persist')->with(self::isInstanceOf(NewsletterConsent::class)); $entityManager->expects(self::once())->method('remove')->with($confirmation); $entityManager->expects(self::once())->method('flush'); $result = $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) ->confirmToken($token); self::assertSame(NewsletterConfirmationResult::STATUS_CONFIRMED, $result->status); } public function testConfirmationEntityNormalizesMailjetListIds(): void { $confirmation = new NewsletterOptInRequest( 'customer@example.com', str_repeat('a', 64), new \DateTimeImmutable('+1 hour'), [2, '1', 2], ); self::assertSame([1, 2], $confirmation->getMailjetListIds()); } /** * @dataProvider invalidEntityMailjetListIdProvider */ public function testConfirmationEntityRejectsInvalidMailjetListIds(mixed $mailjetListId): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Mailjet list IDs must be positive integers.'); new NewsletterOptInRequest( 'customer@example.com', str_repeat('a', 64), new \DateTimeImmutable('+1 hour'), [$mailjetListId], ); } /** * @return iterable */ public static function invalidEntityMailjetListIdProvider(): iterable { yield 'zero' => [0]; yield 'negative integer' => [-1]; yield 'zero string' => ['0']; yield 'decimal' => [1.5]; yield 'numeric prefix' => ['1abc']; yield 'boolean' => [true]; } /** * @param MockObject&NewsletterOptInRequestRepository $repository * @param MockObject&NewsletterConsentRepository $consents * @param MockObject&EntityManagerInterface $entityManager * @param MockObject&ApiClient $mailjet * @param MockObject&Mailer $mailer * @param array $mailjetLists */ private function createManager( NewsletterOptInRequestRepository $repository, NewsletterConsentRepository $consents, EntityManagerInterface $entityManager, ApiClient $mailjet, Mailer $mailer, array $mailjetLists = [ 10321569 => 'E&P Newsletter', 1 => 'List One', 2 => 'List Two', 3 => 'List Three', ], ): NewsletterManager { return new NewsletterManager( optInRequestRepository: $repository, consentRepository: $consents, entityManager: $entityManager, newsletterService: $mailjet, mailer: $mailer, logger: new NullLogger(), newsletterConfirmationTtlHours: 24, mailjetLists: $mailjetLists, defaultMailjetListId: '10321569', ); } }