From 98bee7d4168817bdc892bfc0ec49e204fd0f42d6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 24 Oct 2026 12:09:53 +0400 Subject: [PATCH 1/5] dev-dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fafee39..b6059e5 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "elasticsearch/elasticsearch": "^8.9", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", From 08187143965d1de90637637490849d7662efef75 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 19:12:46 +0400 Subject: [PATCH 2/5] feat: link track controller and tests --- .../Controller/LinkTrackController.php | 103 +++++++++++++ .../Controller/LinkTrackControllerTest.php | 142 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/Statistics/Controller/LinkTrackController.php create mode 100644 tests/Integration/Statistics/Controller/LinkTrackControllerTest.php diff --git a/src/Statistics/Controller/LinkTrackController.php b/src/Statistics/Controller/LinkTrackController.php new file mode 100644 index 0000000..965ef03 --- /dev/null +++ b/src/Statistics/Controller/LinkTrackController.php @@ -0,0 +1,103 @@ +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); + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->error('Failed to track link click', ['exception' => $e]); + + return $this->notFound(); + } + + if ($url === null) { + return $this->notFound(); + } + + $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/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), '+/', '-_'), '='); + } +} From fb0b5f2ae0ae2dd2fc4b00cc80976964905dc641 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Nov 2026 14:28:48 +0400 Subject: [PATCH 3/5] feat: add CampaignMessageDataController and request handling for campaign message data updates --- .../CampaignMessageDataController.php | 128 +++++++++++++++++ src/Messaging/Dto/CampaignMessageData.php | 16 +++ .../Request/UpdateMessageDataRequest.php | 133 ++++++++++++++++++ .../Serializer/MessageDataNormalizer.php | 77 ++++++++++ .../CampaignMessageDataControllerTest.php | 99 +++++++++++++ .../CampaignMessageDataControllerTest.php | 102 ++++++++++++++ .../Request/UpdateMessageDataRequestTest.php | 133 ++++++++++++++++++ .../Serializer/MessageDataNormalizerTest.php | 64 +++++++++ 8 files changed, 752 insertions(+) create mode 100644 src/Messaging/Controller/CampaignMessageDataController.php create mode 100644 src/Messaging/Dto/CampaignMessageData.php create mode 100644 src/Messaging/Request/UpdateMessageDataRequest.php create mode 100644 src/Messaging/Serializer/MessageDataNormalizer.php create mode 100644 tests/Integration/Messaging/Controller/CampaignMessageDataControllerTest.php create mode 100644 tests/Unit/Messaging/Controller/CampaignMessageDataControllerTest.php create mode 100644 tests/Unit/Messaging/Request/UpdateMessageDataRequestTest.php create mode 100644 tests/Unit/Messaging/Serializer/MessageDataNormalizerTest.php 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/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/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']); + } +} From 35f6bc5f1faaa6963de38622a3ce8ace001605b9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Nov 2026 15:11:37 +0400 Subject: [PATCH 4/5] feat: enhance link tracking with error handling and response updates --- src/Statistics/Controller/LinkTrackController.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Statistics/Controller/LinkTrackController.php b/src/Statistics/Controller/LinkTrackController.php index 965ef03..eddee67 100644 --- a/src/Statistics/Controller/LinkTrackController.php +++ b/src/Statistics/Controller/LinkTrackController.php @@ -60,6 +60,7 @@ public function __construct( responses: [ new OA\Response(response: 303, description: 'Redirect to the original url'), new OA\Response(response: 404, description: 'Invalid token or unknown link'), + new OA\Response(response: 500, description: 'The click could not be stored'), ] )] public function trackLinkClick( @@ -79,7 +80,6 @@ public function trackLinkClick( try { $url = $this->linkClickService->trackClick($token, $signature, $metadata); - $this->entityManager->flush(); } catch (Throwable $e) { $this->logger->error('Failed to track link click', ['exception' => $e]); @@ -90,6 +90,12 @@ public function trackLinkClick( 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'); From df43ce56eee9d60ef59f9f053c420a529bea48ce Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 5 Nov 2026 19:54:36 +0400 Subject: [PATCH 5/5] chore: update to php8.2 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/client-docs.yml | 2 +- .github/workflows/front-docs.yml | 2 +- .github/workflows/restapi-docs.yml | 2 +- composer.json | 2 +- src/Messaging/Controller/AttachmentController.php | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) 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 b6059e5..b9f7843 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "source": "https://github.com/phpList/rest-api" }, "require": { - "php": "^8.1", + "php": "^8.2", "phplist/core": "dev-dev", "elasticsearch/elasticsearch": "^8.9", "friendsofsymfony/rest-bundle": "*", 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()) {