diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa1d96c..889a1be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.1'] + php-versions: ['8.2'] dependencies: ['latest', 'oldest'] steps: - name: Checkout @@ -63,7 +63,7 @@ jobs: run: composer validate --no-check-all --no-check-lock --strict; - name: Linting all php files run: find src/ tests/ public/ -name ''*.php'' -print0 | xargs -0 -n 1 -P 4 php -l; php -l; - - name: Running unit tests + - name: Running unit tests run: vendor/bin/phpunit tests/Unit/; continue-on-error: ${{matrix.php-versions == '8.0' }} # [temp-php8] - name: Running the integration tests diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index cbf6eea..b4867be 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -31,7 +31,7 @@ jobs: - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 extensions: mbstring, dom, fileinfo, mysql - name: Cache Composer Dependencies diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml index 0b4784c..f5903f1 100644 --- a/.github/workflows/front-docs.yml +++ b/.github/workflows/front-docs.yml @@ -38,7 +38,7 @@ jobs: - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 extensions: mbstring, dom, fileinfo, mysql - name: Cache Composer Dependencies diff --git a/.github/workflows/restapi-docs.yml b/.github/workflows/restapi-docs.yml index b5ffc02..81480c0 100644 --- a/.github/workflows/restapi-docs.yml +++ b/.github/workflows/restapi-docs.yml @@ -16,7 +16,7 @@ jobs: - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 extensions: mbstring, dom, fileinfo, mysql - name: Cache Composer Dependencies diff --git a/composer.json b/composer.json index fafee39..b9f7843 100644 --- a/composer.json +++ b/composer.json @@ -41,8 +41,8 @@ "source": "https://github.com/phpList/rest-api" }, "require": { - "php": "^8.1", - "phplist/core": "dev-main", + "php": "^8.2", + "phplist/core": "dev-dev", "elasticsearch/elasticsearch": "^8.9", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", diff --git a/src/Messaging/Controller/AttachmentController.php b/src/Messaging/Controller/AttachmentController.php index c9754bb..50c1d5b 100644 --- a/src/Messaging/Controller/AttachmentController.php +++ b/src/Messaging/Controller/AttachmentController.php @@ -75,7 +75,7 @@ public function download( } return new StreamedResponse( - callback: function () use ($downloadable) { + function () use ($downloadable) { $stream = $downloadable->content; if ($stream->isSeekable()) { diff --git a/src/Messaging/Controller/CampaignMessageDataController.php b/src/Messaging/Controller/CampaignMessageDataController.php new file mode 100644 index 0000000..e7e693a --- /dev/null +++ b/src/Messaging/Controller/CampaignMessageDataController.php @@ -0,0 +1,128 @@ + '\d+'], + methods: ['PATCH'] + )] + #[OA\Patch( + path: '/api/v2/campaigns/{messageId}/message-data', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Sets campaign settings that are not part of the campaign itself: analytics tracking, notification ' . + 'addresses and excluded lists. Only the fields that are sent are changed.', + summary: 'Set campaign message data.', + requestBody: new OA\RequestBody( + description: 'The settings to change.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/UpdateMessageDataRequest') + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success, returns all the settings of the campaign', + content: new OA\JsonContent(ref: '#/components/schemas/MessageData') + ), + new OA\Response( + response: 401, + description: 'Not authenticated', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function updateMessageData( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, + ): JsonResponse { + $authUser = $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + if (!$this->permissionChecker->canEdit($authUser, $message)) { + throw $this->createAccessDeniedException('You are not allowed to update campaigns.'); + } + + /** @var UpdateMessageDataRequest $messageDataRequest */ + $messageDataRequest = $this->validator->validate($request, UpdateMessageDataRequest::class); + + $this->messageDataManager->storeMessageData($message, $messageDataRequest->getDto()); + $this->entityManager->flush(); + + $messageData = new CampaignMessageData(($this->messageDataLoader)($message)); + + return $this->json($this->messageDataNormalizer->normalize($messageData), Response::HTTP_OK); + } +} diff --git a/src/Messaging/Dto/CampaignMessageData.php b/src/Messaging/Dto/CampaignMessageData.php new file mode 100644 index 0000000..a86afdb --- /dev/null +++ b/src/Messaging/Dto/CampaignMessageData.php @@ -0,0 +1,16 @@ + $values */ + public function __construct(public readonly array $values) + { + } +} diff --git a/src/Messaging/Request/UpdateMessageDataRequest.php b/src/Messaging/Request/UpdateMessageDataRequest.php new file mode 100644 index 0000000..36a42d1 --- /dev/null +++ b/src/Messaging/Request/UpdateMessageDataRequest.php @@ -0,0 +1,133 @@ + $this->notifyStart, 'notifyEnd' => $this->notifyEnd] as $property => $value) { + if ($value === null || $value === '') { + continue; + } + + foreach (array_map('trim', explode(',', $value)) as $address) { + $context->getValidator() + ->inContext($context) + ->atPath($property) + ->validate($address, [new Assert\NotBlank(), new Assert\Email()]); + } + } + } + + /** + * The settings that were sent, by their message data name. + * + * @return array> + */ + public function getDto(): array + { + return array_filter([ + 'google_track' => $this->googleTrack, + 'utm_source' => $this->utmSource, + 'utm_medium' => $this->utmMedium, + 'utm_campaign' => $this->utmCampaign, + 'pk_source' => $this->pkSource, + 'pk_medium' => $this->pkMedium, + 'pk_campaign' => $this->pkCampaign, + 'notify_start' => $this->notifyStart, + 'notify_end' => $this->notifyEnd, + // like phplist3, keyed by list id + 'excludelist' => $this->excludeLists === null + ? null + : array_combine($this->excludeLists, $this->excludeLists), + ], static fn (mixed $value): bool => $value !== null); + } +} diff --git a/src/Messaging/Serializer/MessageDataNormalizer.php b/src/Messaging/Serializer/MessageDataNormalizer.php new file mode 100644 index 0000000..353f4bf --- /dev/null +++ b/src/Messaging/Serializer/MessageDataNormalizer.php @@ -0,0 +1,77 @@ + response field => message data name */ + private const TEXT_FIELDS = [ + 'utm_source' => 'utm_source', + 'utm_medium' => 'utm_medium', + 'utm_campaign' => 'utm_campaign', + 'pk_source' => 'pk_source', + 'pk_medium' => 'pk_medium', + 'pk_campaign' => 'pk_campaign', + 'notify_start' => 'notify_start', + 'notify_end' => 'notify_end', + ]; + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function normalize($object, string $format = null, array $context = []): array + { + if (!$object instanceof CampaignMessageData) { + return []; + } + + $values = $object->values; + $result = ['google_track' => filter_var($values['google_track'] ?? false, FILTER_VALIDATE_BOOL)]; + foreach (self::TEXT_FIELDS as $field => $name) { + $result[$field] = $values[$name] ?? null; + } + $result['exclude_lists'] = array_values(array_map('intval', (array) ($values['excludelist'] ?? []))); + + return $result; + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof CampaignMessageData; + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function getSupportedTypes(?string $format): array + { + return [ + CampaignMessageData::class => true, + ]; + } +} diff --git a/src/Statistics/Controller/LinkTrackController.php b/src/Statistics/Controller/LinkTrackController.php new file mode 100644 index 0000000..eddee67 --- /dev/null +++ b/src/Statistics/Controller/LinkTrackController.php @@ -0,0 +1,109 @@ +notFound(); + } + + $metadata = [ + 'HTTP_USER_AGENT' => $request->server->get('HTTP_USER_AGENT'), + 'HTTP_REFERER' => $request->server->get('HTTP_REFERER'), + 'client_ip' => $request->getClientIp(), + ]; + + try { + $url = $this->linkClickService->trackClick($token, $signature, $metadata); + } catch (Throwable $e) { + $this->logger->error('Failed to track link click', ['exception' => $e]); + + return $this->notFound(); + } + + if ($url === null) { + return $this->notFound(); + } + + try { + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->error('Failed to store link click', ['exception' => $e]); + } + + $response = new RedirectResponse($url, Response::HTTP_SEE_OTHER); + $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); + + return $response; + } + + private function notFound(): Response + { + return new Response('Not found', Response::HTTP_NOT_FOUND, ['Content-Type' => 'text/plain']); + } +} diff --git a/tests/Integration/Messaging/Controller/CampaignMessageDataControllerTest.php b/tests/Integration/Messaging/Controller/CampaignMessageDataControllerTest.php new file mode 100644 index 0000000..d239238 --- /dev/null +++ b/tests/Integration/Messaging/Controller/CampaignMessageDataControllerTest.php @@ -0,0 +1,99 @@ +get(CampaignMessageDataController::class) + ); + } + + public function testUpdateMessageDataWithoutSessionReturnsUnauthorized(): void + { + $this->loadFixtures([MessageFixture::class]); + + $this->jsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'google_track' => true, + ])); + $this->assertHttpUnauthorized(); + } + + public function testUpdateMessageDataWithInvalidIdReturnsNotFound(): void + { + $this->loadFixtures([AdministratorFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/999/message-data', [], [], [], json_encode([ + 'google_track' => true, + ])); + $this->assertHttpNotFound(); + } + + public function testUpdateMessageDataStoresTheSettingsAndReturnsThem(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class, SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'google_track' => true, + 'utm_source' => 'newsletter', + 'notify_start' => 'a@example.com, b@example.com', + 'exclude_lists' => [1], + ])); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertTrue($response['google_track']); + self::assertSame('newsletter', $response['utm_source']); + self::assertSame('a@example.com, b@example.com', $response['notify_start']); + self::assertSame([1], $response['exclude_lists']); + } + + public function testUpdateMessageDataOnlyChangesTheFieldsThatAreSent(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'utm_source' => 'newsletter', + 'utm_medium' => 'email', + ])); + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'utm_medium' => '', + ])); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('newsletter', $response['utm_source']); + self::assertSame('', $response['utm_medium']); + } + + public function testUpdateMessageDataWithInvalidValuesReturnsUnprocessableEntity(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'notify_end' => 'not-an-email', + ])); + $this->assertHttpUnprocessableEntity(); + } + + public function testUpdateMessageDataWithUnknownExcludedListReturnsNotFound(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'exclude_lists' => [999], + ])); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Statistics/Controller/LinkTrackControllerTest.php b/tests/Integration/Statistics/Controller/LinkTrackControllerTest.php new file mode 100644 index 0000000..f2371b0 --- /dev/null +++ b/tests/Integration/Statistics/Controller/LinkTrackControllerTest.php @@ -0,0 +1,142 @@ +get(LinkTrackController::class) + ); + } + + public function testClickRedirectsToTheOriginalUrlAndIsRecorded(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + UserMessageFixture::class, + ]); + $forwardId = $this->createForward('https://example.org/landing?a=1'); + + self::getClient()->request('GET', '/api/v2/lt?tid=' . $this->token('H')); + + $response = self::getClient()->getResponse(); + self::assertSame(303, $response->getStatusCode()); + self::assertSame('https://example.org/landing?a=1', $response->headers->get('Location')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + + $this->entityManager->clear(); + $ml = $this->entityManager->getRepository(LinkTrackMl::class) + ->findOneBy(['messageId' => 1, 'forwardId' => $forwardId]); + self::assertSame(1, $ml->getClicked()); + self::assertSame(1, $ml->getHtmlClicked()); + $uml = $this->entityManager->getRepository(LinkTrackUmlClick::class) + ->findOneBy(['messageId' => 1, 'userId' => 1, 'forwardId' => $forwardId]); + self::assertSame(1, $uml->getClicked()); + $userMessage = $this->entityManager->getRepository(UserMessage::class)->findOneBy([]); + self::assertTrue($userMessage->isViewed(), 'the first click also counts as a view'); + } + + public function testSecondClickIncrementsTheCounters(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + UserMessageFixture::class, + ]); + $forwardId = $this->createForward('https://example.org/landing'); + + self::getClient()->request('GET', '/api/v2/lt?tid=' . $this->token('H')); + self::getClient()->request('GET', '/api/v2/lt?tid=' . $this->token('T')); + + $this->entityManager->clear(); + $uml = $this->entityManager->getRepository(LinkTrackUmlClick::class) + ->findOneBy(['messageId' => 1, 'userId' => 1, 'forwardId' => $forwardId]); + self::assertSame(2, $uml->getClicked()); + self::assertSame(1, $uml->getHtmlClicked()); + self::assertSame(1, $uml->getTextClicked()); + } + + /** @dataProvider invalidRequestProvider */ + public function testInvalidRequestsReturnNotFound(string $query): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + ]); + $this->createForward('https://example.org/landing'); + + self::getClient()->request('GET', '/api/v2/lt' . $query); + + self::assertSame(404, self::getClient()->getResponse()->getStatusCode()); + self::assertNull(self::getClient()->getResponse()->headers->get('Location')); + } + + public static function invalidRequestProvider(): array + { + $encode = static fn (string $value): string => rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + + return [ + 'no token' => [''], + 'garbage token' => ['?tid=not-a-token'], + 'unknown link' => ['?tid=' . $encode('H|unknown|' . self::MESSAGE_UUID . '|' . self::SUBSCRIBER_UUID)], + 'unknown campaign' => ['?tid=' . $encode('H|' . self::FORWARD_UUID . '|unknown|' . self::SUBSCRIBER_UUID)], + 'unknown subscriber' => [ + '?tid=' . $encode('H|' . self::FORWARD_UUID . '|' . self::MESSAGE_UUID . '|unknown'), + ], + ]; + } + + private function createForward(string $url): int + { + $subscriber = $this->entityManager->find(Subscriber::class, 1); + $subscriber->setUuid(self::SUBSCRIBER_UUID); + + $forward = (new LinkTrackForward()) + ->setUrl($url) + ->setUrlHash(md5($url)) + ->setUuid(self::FORWARD_UUID) + ->setPersonalise(false); + $this->entityManager->persist($forward); + $this->entityManager->flush(); + $this->entityManager->clear(); + + return (int) $forward->getId(); + } + + private function token(string $type): string + { + $value = implode('|', [$type, self::FORWARD_UUID, self::MESSAGE_UUID, self::SUBSCRIBER_UUID]); + + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/tests/Unit/Messaging/Controller/CampaignMessageDataControllerTest.php b/tests/Unit/Messaging/Controller/CampaignMessageDataControllerTest.php new file mode 100644 index 0000000..e6ae39b --- /dev/null +++ b/tests/Unit/Messaging/Controller/CampaignMessageDataControllerTest.php @@ -0,0 +1,102 @@ +authentication = $this->createMock(Authentication::class); + $this->authentication->method('authenticateByApiKey')->willReturn($this->createMock(Administrator::class)); + $this->validator = $this->createMock(RequestValidator::class); + $this->messageDataManager = $this->createMock(MessageDataManager::class); + $this->messageDataLoader = $this->createMock(MessageDataLoader::class); + $this->permissionChecker = $this->createMock(PermissionChecker::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->controller = new CampaignMessageDataController( + $this->authentication, + $this->validator, + $this->messageDataManager, + $this->messageDataLoader, + $this->permissionChecker, + $this->entityManager, + new MessageDataNormalizer() + ); + $this->controller->setContainer(new Container()); + $this->message = $this->createMock(Message::class); + } + + public function testMissingCampaignIsNotFound(): void + { + $this->messageDataManager->expects(self::never())->method('storeMessageData'); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->updateMessageData(new Request(), null); + } + + public function testCampaignThatCannotBeEditedIsRejected(): void + { + $this->permissionChecker->method('canEdit')->willReturn(false); + $this->messageDataManager->expects(self::never())->method('storeMessageData'); + + $this->expectException(AccessDeniedException::class); + + $this->controller->updateMessageData(new Request(), $this->message); + } + + public function testSettingsAreStoredFlushedAndTheLoadedMessageDataReturned(): void + { + $this->permissionChecker->method('canEdit')->willReturn(true); + $messageDataRequest = new UpdateMessageDataRequest(); + $messageDataRequest->googleTrack = true; + $messageDataRequest->excludeLists = [4, 6]; + $this->validator->method('validate')->willReturn($messageDataRequest); + + $this->messageDataManager->expects(self::once()) + ->method('storeMessageData') + ->with($this->message, ['google_track' => true, 'excludelist' => [4 => 4, 6 => 6]]); + $this->entityManager->expects(self::once())->method('flush'); + $this->messageDataLoader->method('__invoke')->willReturn([ + 'google_track' => '1', + 'excludelist' => [4 => 4, 6 => 6], + ]); + + $response = $this->controller->updateMessageData(new Request(), $this->message); + + $data = json_decode((string) $response->getContent(), true); + self::assertTrue($data['google_track']); + self::assertSame([4, 6], $data['exclude_lists']); + self::assertNull($data['utm_source']); + } +} diff --git a/tests/Unit/Messaging/Request/UpdateMessageDataRequestTest.php b/tests/Unit/Messaging/Request/UpdateMessageDataRequestTest.php new file mode 100644 index 0000000..e269790 --- /dev/null +++ b/tests/Unit/Messaging/Request/UpdateMessageDataRequestTest.php @@ -0,0 +1,133 @@ +enableAttributeMapping()->getValidator() + ->validate($request) + ->count(); + } + + public function testNothingIsSetByDefault(): void + { + $request = new UpdateMessageDataRequest(); + + self::assertSame([], $request->getDto()); + self::assertSame(0, $this->violationCount($request)); + } + + public function testOnlyTheFieldsThatWereSentAreReturned(): void + { + $request = new UpdateMessageDataRequest(); + $request->googleTrack = false; + $request->utmSource = 'newsletter'; + $request->utmMedium = ''; + $request->excludeLists = [4, 5]; + + self::assertSame( + [ + 'google_track' => false, + 'utm_source' => 'newsletter', + 'utm_medium' => '', + 'excludelist' => [4 => 4, 5 => 5], + ], + $request->getDto() + ); + } + + /** + * @dataProvider notifyAddressesProvider + */ + public function testNotifyAddressesAreValidated(string $value, int $expectedViolations): void + { + $request = new UpdateMessageDataRequest(); + $request->notifyStart = $value; + $request->notifyEnd = $value; + + self::assertSame($expectedViolations * 2, $this->violationCount($request)); + } + + public static function notifyAddressesProvider(): array + { + return [ + 'empty clears the setting' => ['', 0], + 'one address' => ['a@example.com', 0], + 'several addresses' => ['a@example.com, b@example.com', 0], + 'not an address' => ['nope', 1], + 'one of several is wrong' => ['a@example.com, nope', 1], + 'empty segment between addresses' => ['a@example.com,,b@example.com', 1], + 'trailing comma' => ['a@example.com,', 1], + 'only blanks' => [' ', 1], + ]; + } + + public function testTooLongValuesAreRejected(): void + { + $request = new UpdateMessageDataRequest(); + $request->utmSource = str_repeat('a', 256); + + self::assertSame(1, $this->violationCount($request)); + } + + /** + * @dataProvider invalidExcludeListsProvider + */ + public function testInvalidExcludeListsGiveViolationsInsteadOfNotFound(array $excludeLists): void + { + $repository = $this->createMock(SubscriberListRepository::class); + $repository->method('find')->willReturn(null); + + $factory = new class ($repository) extends ConstraintValidatorFactory { + public function __construct(private readonly SubscriberListRepository $repository) + { + parent::__construct(); + } + + public function getInstance(Constraint $constraint): ConstraintValidatorInterface + { + return $constraint->validatedBy() === ListExistsValidator::class + ? new ListExistsValidator($this->repository) + : parent::getInstance($constraint); + } + }; + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory($factory) + ->getValidator(); + + $request = new UpdateMessageDataRequest(); + $request->excludeLists = $excludeLists; + + try { + $violations = $validator->validate($request); + } catch (NotFoundHttpException) { + self::fail('The list lookup ran for an element that failed the type checks.'); + } + + self::assertSame(1, $violations->count()); + } + + public static function invalidExcludeListsProvider(): array + { + return [ + 'non numeric' => [['abc']], + 'negative' => [[-1]], + 'zero' => [[0]], + ]; + } +} diff --git a/tests/Unit/Messaging/Serializer/MessageDataNormalizerTest.php b/tests/Unit/Messaging/Serializer/MessageDataNormalizerTest.php new file mode 100644 index 0000000..d0350dc --- /dev/null +++ b/tests/Unit/Messaging/Serializer/MessageDataNormalizerTest.php @@ -0,0 +1,64 @@ +normalizer = new MessageDataNormalizer(); + } + + public function testSupportsNormalizationOnlyForCampaignMessageData(): void + { + $this->assertTrue($this->normalizer->supportsNormalization(new CampaignMessageData([]))); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsEmptyArrayForOtherObjects(): void + { + $this->assertSame([], $this->normalizer->normalize(new \stdClass())); + } + + public function testNormalizeReturnsOnlyTheSettingsWithNullForTheOnesNotStored(): void + { + $result = $this->normalizer->normalize(new CampaignMessageData([ + 'google_track' => '1', + 'utm_source' => 'newsletter', + 'excludelist' => [4 => 4, 6 => 6], + 'subject' => 'not part of the response', + ])); + + $this->assertSame( + [ + 'google_track' => true, + 'utm_source' => 'newsletter', + 'utm_medium' => null, + 'utm_campaign' => null, + 'pk_source' => null, + 'pk_medium' => null, + 'pk_campaign' => null, + 'notify_start' => null, + 'notify_end' => null, + 'exclude_lists' => [4, 6], + ], + $result + ); + } + + public function testNormalizeHandlesEmptyData(): void + { + $result = $this->normalizer->normalize(new CampaignMessageData([])); + + $this->assertFalse($result['google_track']); + $this->assertSame([], $result['exclude_lists']); + } +}