diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index 5a8204b8..b961c1e9 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -3,13 +3,24 @@ name: Update phplist-api-client OpenAPI on: push: branches: - - main + - '**' pull_request: jobs: generate-openapi: runs-on: ubuntu-22.04 + outputs: + source_branch: ${{ steps.branch.outputs.source_branch }} steps: + - name: Determine source branch + id: branch + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "source_branch=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + else + echo "source_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + - name: Checkout Source Repository uses: actions/checkout@v3 @@ -42,6 +53,8 @@ jobs: update-phplist-api-client: runs-on: ubuntu-22.04 needs: generate-openapi + env: + TARGET_BRANCH: ${{ needs.generate-openapi.outputs.source_branch }} steps: - name: Checkout phplist-api-client Repository uses: actions/checkout@v3 @@ -50,6 +63,17 @@ jobs: token: ${{ secrets.PUSH_API_CLIENT }} fetch-depth: 0 + - name: Prepare target branch + run: | + git fetch origin + + if git ls-remote --exit-code --heads origin "$TARGET_BRANCH" >/dev/null 2>&1; then + git checkout "$TARGET_BRANCH" + git pull --rebase origin "$TARGET_BRANCH" + else + git checkout -b "$TARGET_BRANCH" + fi + - name: Download Generated OpenAPI JSON uses: actions/download-artifact@v4 with: @@ -63,24 +87,31 @@ jobs: if [ -f openapi.json ]; then diff openapi.json new-openapi/latest-restapi.json > openapi-diff.txt || true if [ -s openapi-diff.txt ]; then - echo "diff=true" >> $GITHUB_OUTPUT + echo "diff=true" >> "$GITHUB_OUTPUT" else - echo "diff=false" >> $GITHUB_OUTPUT + echo "diff=false" >> "$GITHUB_OUTPUT" fi else echo "No previous openapi.json, will add." - echo "diff=true" >> $GITHUB_OUTPUT + echo "diff=true" >> "$GITHUB_OUTPUT" fi - name: Update and Commit OpenAPI File if: steps.diff.outputs.diff == 'true' run: | + set -euo pipefail cp new-openapi/latest-restapi.json openapi.json git config user.name "github-actions" git config user.email "github-actions@phplist-api-client.workflow" git add openapi.json - git commit -m "Update openapi.json from REST API workflow `date`" - git push + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + git commit -m "Update openapi.json from REST API workflow $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + git fetch origin "$TARGET_BRANCH" + git rebase "origin/$TARGET_BRANCH" + git push origin HEAD:"$TARGET_BRANCH" - name: Skip Commit if No Changes if: steps.diff.outputs.diff == 'false' diff --git a/composer.json b/composer.json index 92598e82..477d302b 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", diff --git a/config/services/managers.yml b/config/services/managers.yml index 99253992..34b4a2a0 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -8,7 +8,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\SessionManager: + PhpList\Core\Domain\Identity\Service\Manager\SessionManager: autowire: true autoconfigure: true @@ -36,7 +36,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdministratorManager: + PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager: autowire: true autoconfigure: true @@ -56,6 +56,6 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\PasswordManager: + PhpList\Core\Domain\Identity\Service\Manager\PasswordManager: autowire: true autoconfigure: true diff --git a/config/services/messenger_handlers.yml b/config/services/messenger_handlers.yml index 9073d52f..6b5a0ee4 100644 --- a/config/services/messenger_handlers.yml +++ b/config/services/messenger_handlers.yml @@ -1,5 +1,10 @@ services: - PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\TestCampaignProcessorMessageHandler: autowire: true autoconfigure: true public: false diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index b6176579..cf6fb72e 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -15,5 +15,11 @@ services: $classMetadataFactory: '@?serializer.mapping.class_metadata_factory' $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter' + phplist.request_serializer: + class: Symfony\Component\Serializer\Serializer + arguments: + $normalizers: + - '@Symfony\Component\Serializer\Normalizer\ObjectNormalizer' + PhpList\RestBundle\: resource: '../../src/*/Serializer/*' diff --git a/config/services/validators.yml b/config/services/validators.yml index a4fa508e..5fdbbe0a 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -1,7 +1,7 @@ services: PhpList\RestBundle\Common\Validator\RequestValidator: arguments: - $serializer: '@Symfony\Component\Serializer\Normalizer\ObjectNormalizer' + $serializer: '@phplist.request_serializer' $validator: '@validator' PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmailValidator: @@ -50,4 +50,3 @@ services: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] - diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index f2724628..d490c24e 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -15,7 +15,9 @@ use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Exception\ValidatorException; class ExceptionListener @@ -24,7 +26,6 @@ class ExceptionListener SubscriptionCreationException::class => null, AttributeDefinitionCreationException::class => null, AdminAttributeCreationException::class => null, - ValidatorException::class => 400, AccessDeniedException::class => 403, AccessDeniedHttpException::class => 403, AttachmentFileNotFoundException::class => 404, @@ -36,14 +37,34 @@ public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); + if ($exception instanceof ValidationFailedException + || $exception instanceof ValidatorException + || $exception instanceof UnprocessableEntityHttpException + ) { + $event->setResponse( + new JsonResponse([ + 'message' => 'Validation failed', + 'errors' => $this->parseFlatValidationMessage($exception->getMessage()), + ], 422) + ); + + return; + } + foreach (self::EXCEPTION_STATUS_MAP as $class => $statusCode) { if ($exception instanceof $class) { - $status = $statusCode ?? $exception->getStatusCode(); + $status = $statusCode ?? ( + method_exists($exception, 'getStatusCode') + ? $exception->getStatusCode() + : 400 + ); + $event->setResponse( new JsonResponse([ - 'message' => $exception->getMessage() + 'message' => $exception->getMessage(), ], $status) ); + return; } } @@ -51,18 +72,50 @@ public function onKernelException(ExceptionEvent $event): void if ($exception instanceof HttpExceptionInterface) { $event->setResponse( new JsonResponse([ - 'message' => $exception->getMessage() + 'message' => $exception->getMessage(), ], $exception->getStatusCode()) ); + return; } if ($exception instanceof Exception) { $event->setResponse( new JsonResponse([ - 'message' => $exception->getMessage() + 'message' => $exception->getMessage(), ], 500) ); } } + + /** + * @return array> + */ + private function parseFlatValidationMessage(string $message): array + { + $errors = []; + $lines = preg_split('/\r\n|\r|\n/', $message) ?: []; + + foreach ($lines as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + $parts = explode(':', $line, 2); + + if (count($parts) !== 2) { + $errors['_global'][] = $line; + continue; + } + + $field = trim($parts[0]); + $errorMessage = trim($parts[1]); + + $errors[$field][] = $errorMessage; + } + + return $errors; + } } diff --git a/src/Common/SwaggerSchemasResponse.php b/src/Common/SwaggerSchemasResponse.php index 8c650f9d..6f4ad0f3 100644 --- a/src/Common/SwaggerSchemasResponse.php +++ b/src/Common/SwaggerSchemasResponse.php @@ -6,8 +6,22 @@ use OpenApi\Attributes as OA; +#[OA\Schema( + schema: 'ErrorDetails', + type: 'object', + example: [ + 'format.formatOptions[0]' => ['The value you selected is not a valid choice.'], + 'schedule.repeatUntil' => ['This value is not a valid datetime.'], + 'schedule.requeueUntil' => ['This value is not a valid datetime.'], + ], + additionalProperties: new OA\AdditionalProperties( + type: 'array', + items: new OA\Items(type: 'string') + ) +)] #[OA\Schema( schema: 'UnauthorizedResponse', + required: ['message'], properties: [ new OA\Property( property: 'message', @@ -19,17 +33,23 @@ )] #[OA\Schema( schema: 'ValidationErrorResponse', + required: ['message', 'errors'], properties: [ new OA\Property( property: 'message', type: 'string', - example: 'Some fields are invalid' + example: 'Validation failed' + ), + new OA\Property( + property: 'errors', + ref: '#/components/schemas/ErrorDetails' ) ], type: 'object' )] #[OA\Schema( schema: 'BadRequestResponse', + required: ['message'], properties: [ new OA\Property( property: 'message', @@ -41,6 +61,7 @@ )] #[OA\Schema( schema: 'AlreadyExistsResponse', + required: ['message'], properties: [ new OA\Property( property: 'message', @@ -62,7 +83,18 @@ ], type: 'object' )] - +#[OA\Schema( + schema: 'GenericErrorResponse', + required: ['message'], + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'An unexpected error occurred.' + ) + ], + type: 'object' +)] #[OA\Schema( schema: 'CursorPagination', properties: [ diff --git a/src/Identity/Serializer/AdministratorTokenNormalizer.php b/src/Identity/Serializer/AdministratorTokenNormalizer.php index 7f6df4d6..a04e8a7f 100644 --- a/src/Identity/Serializer/AdministratorTokenNormalizer.php +++ b/src/Identity/Serializer/AdministratorTokenNormalizer.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Serializer; +use DateTimeInterface; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -21,7 +22,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'key' => $object->getKey(), - 'expiry_date' => $object->getExpiry()->format('Y-m-d\TH:i:sP'), + 'expiry_date' => $object->getExpiry()->format(DateTimeInterface::ATOM), ]; } diff --git a/src/Messaging/Controller/CampaignActionController.php b/src/Messaging/Controller/CampaignActionController.php new file mode 100644 index 00000000..326f832d --- /dev/null +++ b/src/Messaging/Controller/CampaignActionController.php @@ -0,0 +1,370 @@ + + * @SuppressWarnings("PHPMD.CouplingBetweenObjects") + */ +#[Route('/campaigns', name: 'campaign_')] +class CampaignActionController extends BaseController +{ + public function __construct( + Authentication $authentication, + RequestValidator $validator, + private readonly CampaignService $campaignService, + private readonly MessageBusInterface $messageBus, + private readonly EntityManagerInterface $entityManager, + private readonly MessageManager $messageManager, + private readonly MessageNormalizer $messageNormalizer + ) { + parent::__construct($authentication, $validator); + } + + #[Route('/{messageId}/copy', name: 'copy_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/copy', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Copies campaign/message by id into a draft message.', + summary: 'Copies campaign/message by id.', + 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: 201, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + 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') + ), + ] + )] + public function copyMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $authUser = $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + $message = $this->messageManager->copyAsDraftMessage($message, $authUser); + $this->entityManager->flush(); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_CREATED); + } + + #[Route('/{messageId}/status', name: 'update_status', requirements: ['messageId' => '\d+'], methods: ['PATCH'])] + #[OA\Patch( + path: '/api/v2/campaigns/{messageId}/status', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Updates campaign/message status by id.', + summary: 'Update campaign status by id.', + requestBody: new OA\RequestBody( + description: 'Update message status.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/MessageMetadataRequest') + ), + 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', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + 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 updateMessageStatus( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Message not found.'); + } + + /** @var MessageMetadataRequest $messageMetadataRequest */ + $messageMetadataRequest = $this->validator->validate($request, MessageMetadataRequest::class); + + $message = $this->messageManager->updateStatus( + $message, + MessageStatus::from($messageMetadataRequest->status), + ); + $this->entityManager->flush(); + + return $this->json($this->messageNormalizer->normalize($message), Response::HTTP_OK); + } + + #[Route('/{messageId}/send', name: 'send_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/send', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Processes/sends campaign/message by id.', + summary: 'Processes/sends campaign/message by id.', + 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', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + 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') + ), + ] + )] + public function sendMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + $this->messageBus->dispatch(new SyncCampaignProcessorMessage($message->getId())); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); + } + + #[Route('/{messageId}/resend', name: 'resend_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/resend', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Processes/sends campaign/message by id to specified mailing lists.', + summary: 'Processes/sends campaign/message by id to lists.', + requestBody: new OA\RequestBody( + description: 'List ids to send this campaign to.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ResendMessageToListsRequest') + ), + 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', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + 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') + ), + ] + )] + public function resendMessageToLists( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + /** @var ResendMessageToListsRequest $resendToListsRequest */ + $resendToListsRequest = $this->validator->validate($request, ResendMessageToListsRequest::class); + + $this->messageBus->dispatch( + new SyncCampaignProcessorMessage($message->getId(), $resendToListsRequest->listIds) + ); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); + } + + #[Route( + '/{messageId}/test-send', + name: 'test_send_campaign', + requirements: ['messageId' => '\d+'], + methods: ['POST'] + )] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/test-send', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Processes/sends campaign/message by id to specified subscribers.', + summary: 'Processes/sends campaign/message by id to specified subscribers.', + requestBody: new OA\RequestBody( + description: 'Subscribers email to send this campaign to.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ResendMessageToListsRequest') + ), + 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', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + 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') + ), + ] + )] + public function testSendMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + /** @var TestSendMessageToSubscribersRequest $testSendRequest */ + $testSendRequest = $this->validator->validate($request, TestSendMessageToSubscribersRequest::class); + + $this->messageBus->dispatch(new TestCampaignProcessorMessage( + messageId: $message->getId(), + subscriberEmails: $testSendRequest->emails + )); + + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); + } +} diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index 1501524a..1769aa7f 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -6,20 +6,17 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Messaging\Request\CreateMessageRequest; -use PhpList\RestBundle\Messaging\Request\ResendMessageToListsRequest; use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest; use PhpList\RestBundle\Messaging\Service\CampaignService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -34,7 +31,6 @@ public function __construct( Authentication $authentication, RequestValidator $validator, private readonly CampaignService $campaignService, - private readonly MessageBusInterface $messageBus, private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); @@ -70,7 +66,14 @@ public function __construct( in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) - ) + ), + new OA\Parameter( + name: 'subject', + description: 'Filter campaigns by subject (partial match)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', maxLength: 50) + ), ], responses: [ new OA\Response( @@ -100,7 +103,7 @@ public function getMessages(Request $request): JsonResponse $authUser = $this->requireAuthentication($request); return $this->json( - $this->campaignService->getMessages($request, $authUser), + $this->campaignService->getMessages(request: $request, administrator: $authUser), Response::HTTP_OK ); } @@ -152,10 +155,10 @@ public function getMessage( Request $request, #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null ): JsonResponse { + $this->requireAuthentication($request); if ($message === null) { throw $this->createNotFoundException('Campaign not found.'); } - $this->requireAuthentication($request); return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); } @@ -356,114 +359,4 @@ public function deleteMessage( return $this->json(null, Response::HTTP_NO_CONTENT); } - - #[Route('/{messageId}/send', name: 'send_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] - #[OA\Post( - path: '/api/v2/campaigns/{messageId}/send', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Processes/sends campaign/message by id.', - summary: 'Processes/sends campaign/message by id.', - 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', - content: new OA\JsonContent(ref: '#/components/schemas/Message') - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function sendMessage( - Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null - ): JsonResponse { - $this->requireAuthentication($request); - if ($message === null) { - throw $this->createNotFoundException('Campaign not found.'); - } - - $this->messageBus->dispatch(new SyncCampaignProcessorMessage($message->getId())); - - return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); - } - - #[Route('/{messageId}/resend', name: 'resend_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])] - #[OA\Post( - path: '/api/v2/campaigns/{messageId}/resend', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Processes/sends campaign/message by id to specified mailing lists.', - summary: 'Processes/sends campaign/message by id to lists.', - requestBody: new OA\RequestBody( - description: 'List ids to send this campaign to.', - required: true, - content: new OA\JsonContent(ref: '#/components/schemas/AdminAttributeDefinitionRequest') - ), - 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', - content: new OA\JsonContent(ref: '#/components/schemas/Message') - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function resendMessageToLists( - Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null - ): JsonResponse { - $this->requireAuthentication($request); - if ($message === null) { - throw $this->createNotFoundException('Campaign not found.'); - } - - /** @var ResendMessageToListsRequest $resendToListsRequest */ - $resendToListsRequest = $this->validator->validate($request, ResendMessageToListsRequest::class); - - $this->messageBus->dispatch( - new SyncCampaignProcessorMessage($message->getId(), $resendToListsRequest->listIds) - ); - - return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); - } } diff --git a/src/Messaging/Controller/ListMessageController.php b/src/Messaging/Controller/ListMessageController.php index f67c8fa9..fe0e80a7 100644 --- a/src/Messaging/Controller/ListMessageController.php +++ b/src/Messaging/Controller/ListMessageController.php @@ -27,25 +27,16 @@ #[Route('/list-messages', name: 'list_message_')] class ListMessageController extends BaseController { - private ListMessageManager $listMessageManager; - private ListMessageNormalizer $listMessageNormalizer; - private SubscriberListNormalizer $subscriberListNormalizer; - private MessageNormalizer $messageNormalizer; - public function __construct( - Authentication $authentication, - RequestValidator $validator, - ListMessageManager $listMessageManager, - ListMessageNormalizer $listMessageNormalizer, - SubscriberListNormalizer $subscriberListNormalizer, - MessageNormalizer $messageNormalizer, + protected Authentication $authentication, + protected RequestValidator $validator, + private readonly ListMessageManager $listMessageManager, + private readonly ListMessageNormalizer $listMessageNormalizer, + private readonly SubscriberListNormalizer $subscriberListNormalizer, + private readonly MessageNormalizer $messageNormalizer, private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); - $this->listMessageManager = $listMessageManager; - $this->listMessageNormalizer = $listMessageNormalizer; - $this->subscriberListNormalizer = $subscriberListNormalizer; - $this->messageNormalizer = $messageNormalizer; } #[Route( @@ -339,6 +330,7 @@ public function disassociateMessageFromList( } $this->listMessageManager->removeAssociation($message, $subscriberList); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -398,6 +390,7 @@ public function removeAllListAssociationsForMessage( } $this->listMessageManager->removeAllListAssociationsForMessage($message); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Messaging/Request/CreateMessageRequest.php b/src/Messaging/Request/CreateMessageRequest.php index 4d07968b..d81fabb7 100644 --- a/src/Messaging/Request/CreateMessageRequest.php +++ b/src/Messaging/Request/CreateMessageRequest.php @@ -38,7 +38,7 @@ class CreateMessageRequest implements RequestInterface public MessageOptionsRequest $options; #[TemplateExists] - public ?int $templateId; + public ?int $templateId = null; public function getDto(): MessageDtoInterface { diff --git a/src/Messaging/Request/Message/MessageContentRequest.php b/src/Messaging/Request/Message/MessageContentRequest.php index a8d759ca..01bff9b0 100644 --- a/src/Messaging/Request/Message/MessageContentRequest.php +++ b/src/Messaging/Request/Message/MessageContentRequest.php @@ -7,38 +7,56 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; #[OA\Schema( schema: 'MessageContentRequest', - required: ['subject', 'text', 'text_message', 'footer'], + required: ['subject', 'text', 'footer'], properties: [ new OA\Property(property: 'subject', type: 'string', example: 'Campaign Subject'), new OA\Property(property: 'text', type: 'string', example: 'Full text content'), - new OA\Property(property: 'text_message', type: 'string', example: 'Short text message'), new OA\Property(property: 'footer', type: 'string', example: 'Unsubscribe link here'), ], type: 'object' )] class MessageContentRequest implements RequestDtoInterface { + private const CLICKTRACK_MESSAGE = 'You should not paste the results of a test message back into the editor. ' + . 'This will break the click-track statistics, and overload the server.'; + #[Assert\NotBlank] public string $subject; #[Assert\NotBlank] public string $text; - #[Assert\NotBlank] - public string $textMessage; - #[Assert\NotBlank] public string $footer; + #[Assert\Callback('validateNoClickTrackLinks')] + public function validateNoClickTrackLinks(ExecutionContextInterface $context): void + { + if (!isset($this->text)) { + return; + } + + $hasClickTrackLinks = preg_match('/lt\.php\?id=[\w%]{22}/', $this->text) === 1 + || preg_match('/lt\.php\?id=[\w%]{16}/', $this->text) === 1 + || preg_match('#/lt/[\w%]{22}#', $this->text) === 1 + || preg_match('#/lt/[\w%]{16}#', $this->text) === 1; + + if ($hasClickTrackLinks) { + $context->buildViolation(self::CLICKTRACK_MESSAGE) + ->atPath('text') + ->addViolation(); + } + } + public function getDto(): MessageContentDto { return new MessageContentDto( subject: $this->subject, text: $this->text, - textMessage: $this->textMessage, footer: $this->footer, ); } diff --git a/src/Messaging/Request/Message/MessageFormatRequest.php b/src/Messaging/Request/Message/MessageFormatRequest.php index 4876706c..90ae287a 100644 --- a/src/Messaging/Request/Message/MessageFormatRequest.php +++ b/src/Messaging/Request/Message/MessageFormatRequest.php @@ -10,44 +10,26 @@ #[OA\Schema( schema: 'MessageFormatRequest', - required: ['html_formated', 'send_format', 'format_options'], + required: ['send_format'], properties: [ - new OA\Property(property: 'html_formated', type: 'boolean', example: true), new OA\Property( property: 'send_format', type: 'string', enum: ['html', 'text', 'invite'], example: 'html' ), - new OA\Property( - property: 'format_options', - type: 'array', - items: new OA\Items(type: 'string', enum: ['text', 'html', 'pdf']), - example: ['html'] - ), ], type: 'object' )] class MessageFormatRequest implements RequestDtoInterface { - #[Assert\Type('bool')] - public bool $htmlFormated; - #[Assert\Choice(['html', 'text', 'invite'])] public string $sendFormat; - #[Assert\All([ - new Assert\Type('string'), - new Assert\Choice(['text', 'html', 'pdf']), - ])] - public array $formatOptions; - public function getDto(): MessageFormatDto { return new MessageFormatDto( - htmlFormated: $this->htmlFormated, sendFormat: $this->sendFormat, - formatOptions: $this->formatOptions, ); } } diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php index 7679d841..2ae5ba3e 100644 --- a/src/Messaging/Request/Message/MessageMetadataRequest.php +++ b/src/Messaging/Request/Message/MessageMetadataRequest.php @@ -7,18 +7,26 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageMetadataDto; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; +use PhpList\RestBundle\Common\Request\RequestInterface; use Symfony\Component\Validator\Constraints as Assert; #[OA\Schema( schema: 'MessageMetadataRequest', + required: ['status'], properties: [ - new OA\Property(property: 'status', type: 'string', example: 'draft'), + new OA\Property( + property: 'status', + type: 'string', + enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'], + example: 'draft' + ), ], type: 'object' )] -class MessageMetadataRequest implements RequestDtoInterface +class MessageMetadataRequest implements RequestDtoInterface, RequestInterface { #[Assert\NotBlank] + #[Assert\Choice(['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'])] public string $status; /** diff --git a/src/Messaging/Request/Message/MessageScheduleRequest.php b/src/Messaging/Request/Message/MessageScheduleRequest.php index 11082269..996017a3 100644 --- a/src/Messaging/Request/Message/MessageScheduleRequest.php +++ b/src/Messaging/Request/Message/MessageScheduleRequest.php @@ -34,12 +34,12 @@ class MessageScheduleRequest implements RequestDtoInterface { public ?int $repeatInterval = null; - #[Assert\DateTime] + #[Assert\DateTime(format: 'Y-m-d\TH:i:s.uP')] public ?string $repeatUntil = null; public ?int $requeueInterval = null; - #[Assert\DateTime] + #[Assert\DateTime(format: 'Y-m-d\TH:i:s.uP')] public ?string $requeueUntil = null; #[Assert\NotBlank] diff --git a/src/Messaging/Request/TestSendMessageToSubscribersRequest.php b/src/Messaging/Request/TestSendMessageToSubscribersRequest.php new file mode 100644 index 00000000..5907bfce --- /dev/null +++ b/src/Messaging/Request/TestSendMessageToSubscribersRequest.php @@ -0,0 +1,44 @@ + $this->emails, + ]; + } +} diff --git a/src/Messaging/Serializer/ListMessageNormalizer.php b/src/Messaging/Serializer/ListMessageNormalizer.php index 514ead23..5b9d1c2f 100644 --- a/src/Messaging/Serializer/ListMessageNormalizer.php +++ b/src/Messaging/Serializer/ListMessageNormalizer.php @@ -51,8 +51,8 @@ public function normalize($object, string $format = null, array $context = []): 'id' => $object->getId(), 'message' => $this->messageNormalizer->normalize($object->getMessage()), 'subscriber_list' => $this->subscriberListNormalizer->normalize($object->getList()), - 'created_at' => $object->getEntered()->format('Y-m-d\TH:i:sP'), - 'updated_at' => $object->getUpdatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getEntered()->format(\DateTimeInterface::ATOM), + 'updated_at' => $object->getUpdatedAt()->format(\DateTimeInterface::ATOM), ]; } diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php index 4b39bfbc..e636bb60 100644 --- a/src/Messaging/Serializer/MessageNormalizer.php +++ b/src/Messaging/Serializer/MessageNormalizer.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Messaging\Serializer; +use DateTimeInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Message; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -35,6 +36,9 @@ new OA\Property(property: 'send_format', type: 'string', example: 'text', nullable: true), new OA\Property(property: 'as_text', type: 'integer', example: 12), new OA\Property(property: 'as_html', type: 'integer', example: 12), + new OA\Property(property: 'as_text_and_html', type: 'integer', example: 12), + new OA\Property(property: 'as_pdf', type: 'integer', example: 12), + new OA\Property(property: 'as_text_and_pdf', type: 'integer', example: 12), ], type: 'object' ), @@ -111,22 +115,25 @@ public function normalize($object, string $format = null, array $context = []): 'send_format' => $object->getFormat()->getSendFormat(), 'as_text' => $object->getFormat()->getAsText(), 'as_html' => $object->getFormat()->getAsHtml(), + 'as_text_and_html' => $object->getFormat()->getAsTextAndHtml(), + 'as_pdf' => $object->getFormat()->getAsPdf(), + 'as_text_and_pdf' => $object->getFormat()->getAsTextAndHtml(), ], 'message_metadata' => [ 'status' => $object->getMetadata()->getStatus()->value, 'processed' => $object->getMetadata()->getProcessed(), 'views' => $object->getMetadata()->getViews(), 'bounce_count' => $object->getMetadata()->getBounceCount(), - 'entered' => $object->getMetadata()->getEntered()?->format('Y-m-d\TH:i:sP'), - 'sent' => $object->getMetadata()->getSent()?->format('Y-m-d\TH:i:sP'), - 'send_start' => $object->getMetadata()->getSendStart()?->format('Y-m-d\TH:i:sP'), + 'entered' => $object->getMetadata()->getEntered()?->format(DateTimeInterface::ATOM), + 'sent' => $object->getMetadata()->getSent()?->format(DateTimeInterface::ATOM), + 'send_start' => $object->getMetadata()->getSendStart()?->format(DateTimeInterface::ATOM), ], 'message_schedule' => [ 'repeat_interval' => $object->getSchedule()->getRepeatInterval(), - 'repeat_until' => $object->getSchedule()->getRepeatUntil()?->format('Y-m-d\TH:i:sP'), + 'repeat_until' => $object->getSchedule()->getRepeatUntil()?->format(DateTimeInterface::ATOM), 'requeue_interval' => $object->getSchedule()->getRequeueInterval(), - 'requeue_until' => $object->getSchedule()->getRequeueUntil()?->format('Y-m-d\TH:i:sP'), - 'embargo' => $object->getSchedule()->getEmbargo()?->format('Y-m-d\TH:i:sP'), + 'requeue_until' => $object->getSchedule()->getRequeueUntil()?->format(DateTimeInterface::ATOM), + 'embargo' => $object->getSchedule()->getEmbargo()?->format(DateTimeInterface::ATOM), ], 'message_options' => [ 'from_field' => $object->getOptions()->getFromField(), diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index 90ec7d25..5f50124b 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -30,7 +30,9 @@ public function __construct( public function getMessages(Request $request, Administrator $administrator): array { - $filter = (new MessageFilter())->setOwner($administrator); + $filter = (new MessageFilter()) + ->setOwner($administrator) + ->setSubject($request->query->get('subject')); return $this->paginatedProvider->getPaginatedList( request: $request, diff --git a/src/Subscription/Serializer/SubscriberHistoryNormalizer.php b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php index 2ab5b437..9189a5e7 100644 --- a/src/Subscription/Serializer/SubscriberHistoryNormalizer.php +++ b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php @@ -39,7 +39,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'ip' => $object->getIp(), - 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format(\DateTimeInterface::ATOM), 'summary' => $object->getSummary(), 'detail' => $object->getDetail(), 'system_info' => $object->getSystemInfo(), diff --git a/src/Subscription/Serializer/SubscriberListNormalizer.php b/src/Subscription/Serializer/SubscriberListNormalizer.php index 00a8ff1b..976fcf2a 100644 --- a/src/Subscription/Serializer/SubscriberListNormalizer.php +++ b/src/Subscription/Serializer/SubscriberListNormalizer.php @@ -42,7 +42,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'name' => $object->getName(), - 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format(\DateTimeInterface::ATOM), 'description' => $object->getDescription(), 'list_position' => $object->getListPosition(), 'subject_prefix' => $object->getSubjectPrefix(), diff --git a/src/Subscription/Serializer/SubscriberNormalizer.php b/src/Subscription/Serializer/SubscriberNormalizer.php index be408714..9ce81556 100644 --- a/src/Subscription/Serializer/SubscriberNormalizer.php +++ b/src/Subscription/Serializer/SubscriberNormalizer.php @@ -67,8 +67,8 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'email' => $object->getEmail(), - 'created_at' => $object->getCreatedAt()?->format('Y-m-d\TH:i:sP'), - 'updated_at' => $object->getUpdatedAt()?->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()?->format(\DateTimeInterface::ATOM), + 'updated_at' => $object->getUpdatedAt()?->format(\DateTimeInterface::ATOM), 'confirmed' => $object->isConfirmed(), 'blacklisted' => $object->isBlacklisted(), 'bounce_count' => $object->getBounceCount(), diff --git a/src/Subscription/Serializer/SubscriberOnlyNormalizer.php b/src/Subscription/Serializer/SubscriberOnlyNormalizer.php index 2227f3ef..0b93d8af 100644 --- a/src/Subscription/Serializer/SubscriberOnlyNormalizer.php +++ b/src/Subscription/Serializer/SubscriberOnlyNormalizer.php @@ -42,7 +42,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'email' => $object->getEmail(), - 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format(\DateTimeInterface::ATOM), 'confirmed' => $object->isConfirmed(), 'blacklisted' => $object->isBlacklisted(), 'bounce_count' => $object->getBounceCount(), diff --git a/src/Subscription/Serializer/SubscriptionNormalizer.php b/src/Subscription/Serializer/SubscriptionNormalizer.php index d4037290..eac2fef8 100644 --- a/src/Subscription/Serializer/SubscriptionNormalizer.php +++ b/src/Subscription/Serializer/SubscriptionNormalizer.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Serializer; +use DateTimeInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Subscription; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -47,7 +48,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'subscriber' => $this->subscriberNormalizer->normalize($object->getSubscriber()), 'subscriber_list' => $this->subscriberListNormalizer->normalize($object->getSubscriberList()), - 'subscription_date' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'subscription_date' => $object->getCreatedAt()->format(DateTimeInterface::ATOM), ]; } diff --git a/src/Subscription/Serializer/UserBlacklistNormalizer.php b/src/Subscription/Serializer/UserBlacklistNormalizer.php index f8ff01c0..466debe7 100644 --- a/src/Subscription/Serializer/UserBlacklistNormalizer.php +++ b/src/Subscription/Serializer/UserBlacklistNormalizer.php @@ -27,7 +27,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'email' => $object->getEmail(), - 'added' => $object->getAdded()?->format('Y-m-d\TH:i:sP'), + 'added' => $object->getAdded()?->format(\DateTimeInterface::ATOM), 'reason' => $reason, ]; } diff --git a/tests/Integration/Common/EventListener/ExceptionListenerTest.php b/tests/Integration/Common/EventListener/ExceptionListenerTest.php index 9b6946dc..d2fb76b6 100644 --- a/tests/Integration/Common/EventListener/ExceptionListenerTest.php +++ b/tests/Integration/Common/EventListener/ExceptionListenerTest.php @@ -34,7 +34,10 @@ public function testAccessDeniedExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(403, $response->getStatusCode()); - $this->assertEquals(['message' => 'Forbidden'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Forbidden'], + json_decode($response->getContent(), true) + ); } public function testHttpExceptionHandled(): void @@ -47,7 +50,10 @@ public function testHttpExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(404, $response->getStatusCode()); - $this->assertEquals(['message' => 'Not found'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Not found'], + json_decode($response->getContent(), true) + ); } public function testSubscriptionCreationExceptionHandled(): void @@ -61,7 +67,10 @@ public function testSubscriptionCreationExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(409, $response->getStatusCode()); - $this->assertEquals(['message' => 'Subscription error'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Subscription error'], + json_decode($response->getContent(), true) + ); } public function testGenericExceptionHandled(): void @@ -74,6 +83,9 @@ public function testGenericExceptionHandled(): void $this->assertInstanceOf(JsonResponse::class, $response); $this->assertEquals(500, $response->getStatusCode()); - $this->assertEquals(['message' => 'Something went wrong'], json_decode($response->getContent(), true)); + $this->assertEquals( + ['message' => 'Something went wrong'], + json_decode($response->getContent(), true) + ); } } diff --git a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php index 08ed439e..53f2cd77 100644 --- a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php +++ b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php @@ -32,7 +32,8 @@ public function testRequestPasswordResetWithInvalidEmailReturnsError422(): void $this->assertHttpUnprocessableEntity(); $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('This value is not a valid email address', $data['message']); + $this->assertStringContainsString('Validation failed', $data['message']); + $this->assertStringContainsString('This value is not a valid email address', $data['errors']['email'][0]); } public function testRequestPasswordResetWithNonExistentEmailReturnsError404(): void @@ -97,6 +98,7 @@ public function testResetPasswordWithShortPasswordReturnsError422(): void $this->assertHttpUnprocessableEntity(); $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('This value is too short', $data['message']); + $this->assertStringContainsString('Validation failed', $data['message']); + $this->assertStringContainsString('This value is too short', $data['errors']['newPassword'][0]); } } diff --git a/tests/Integration/Identity/Controller/SessionControllerTest.php b/tests/Integration/Identity/Controller/SessionControllerTest.php index 2003593c..cb0cd45e 100644 --- a/tests/Integration/Identity/Controller/SessionControllerTest.php +++ b/tests/Integration/Identity/Controller/SessionControllerTest.php @@ -65,7 +65,15 @@ public function testPostSessionsWithValidEmptyJsonWithOtherTypeReturnsError422() $this->assertHttpUnprocessableEntity(); $this->assertJsonResponseContentEquals( [ - 'message' => "loginName: This value should not be blank.\npassword: This value should not be blank.", + 'message' => 'Validation failed', + 'errors' => [ + 'loginName' => [ + 'This value should not be blank.', + ], + 'password' => [ + 'This value should not be blank.', + ], + ], ] ); } @@ -91,7 +99,10 @@ public function testPostSessionsWithValidIncompleteJsonReturnsError400(string $j $this->assertHttpUnprocessableEntity(); $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('This value should not be blank', $data['message']); + $this->assertStringContainsString( + 'Validation failed', + $data['message'] + ); } public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() diff --git a/tests/Integration/Messaging/Controller/CampaignActionControllerTest.php b/tests/Integration/Messaging/Controller/CampaignActionControllerTest.php new file mode 100644 index 00000000..f62d9d87 --- /dev/null +++ b/tests/Integration/Messaging/Controller/CampaignActionControllerTest.php @@ -0,0 +1,77 @@ +get(CampaignActionController::class) + ); + } + + public function testSendMessageWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([MessageFixture::class]); + self::getClient()->request('POST', '/api/v2/campaigns/1/send'); + $this->assertHttpForbidden(); + } + + public function testSendMessageWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/send'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame(2, $response['id']); + } + + public function testSendMessageWithInvalidIdReturnsNotFound(): void + { + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/send'); + $this->assertHttpNotFound(); + } + + public function testResendMessageToListsWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); + + $this->jsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode(['list_ids' => [1]])); + $this->assertHttpForbidden(); + } + + public function testResendMessageToListsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode([ + 'list_ids' => [1], + ])); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame(2, $response['id']); + } + + public function testResendMessageToListsWithInvalidIdReturnsNotFound(): void + { + $this->loadFixtures([SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/resend', [], [], [], json_encode([ + 'list_ids' => [1], + ])); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Messaging/Controller/CampaignControllerTest.php b/tests/Integration/Messaging/Controller/CampaignControllerTest.php index 4b71508e..301dee37 100644 --- a/tests/Integration/Messaging/Controller/CampaignControllerTest.php +++ b/tests/Integration/Messaging/Controller/CampaignControllerTest.php @@ -9,7 +9,6 @@ use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture; use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorTokenFixture; use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\MessageFixture; -use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberListFixture; class CampaignControllerTest extends AbstractTestController { @@ -88,58 +87,4 @@ public function testDeleteCampaignReturnsNoContent(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/campaigns/1'); $this->assertHttpNoContent(); } - public function testSendMessageWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([MessageFixture::class]); - self::getClient()->request('POST', '/api/v2/campaigns/1/send'); - $this->assertHttpForbidden(); - } - - public function testSendMessageWithValidSessionReturnsOkay(): void - { - $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); - - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/send'); - $this->assertHttpOkay(); - - $response = $this->getDecodedJsonResponseContent(); - self::assertSame(2, $response['id']); - } - - public function testSendMessageWithInvalidIdReturnsNotFound(): void - { - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/send'); - $this->assertHttpNotFound(); - } - - public function testResendMessageToListsWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); - - $this->jsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode(['list_ids' => [1]])); - $this->assertHttpForbidden(); - } - - public function testResendMessageToListsWithValidSessionReturnsOkay(): void - { - $this->loadFixtures([MessageFixture::class, SubscriberListFixture::class]); - - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/resend', [], [], [], json_encode([ - 'list_ids' => [1], - ])); - $this->assertHttpOkay(); - - $response = $this->getDecodedJsonResponseContent(); - self::assertSame(2, $response['id']); - } - - public function testResendMessageToListsWithInvalidIdReturnsNotFound(): void - { - $this->loadFixtures([SubscriberListFixture::class]); - - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/999/resend', [], [], [], json_encode([ - 'list_ids' => [1], - ])); - $this->assertHttpNotFound(); - } } diff --git a/tests/Unit/Messaging/Request/MessageContentRequestTest.php b/tests/Unit/Messaging/Request/MessageContentRequestTest.php new file mode 100644 index 00000000..520e3dd5 --- /dev/null +++ b/tests/Unit/Messaging/Request/MessageContentRequestTest.php @@ -0,0 +1,58 @@ +text = 'Hello, this is a normal message body.'; + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $request->validateNoClickTrackLinks($context); + } + + public function testValidateNoClickTrackLinksWithLtPhpPatternAddsViolation(): void + { + $request = new MessageContentRequest(); + $request->text = 'See this link: https://example.com/lt.php?id=abcdefghijklmnop'; + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('atPath')->with('text')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->willReturn($builder); + + $request->validateNoClickTrackLinks($context); + } + + public function testValidateNoClickTrackLinksWithLinkMapPatternAddsViolation(): void + { + $request = new MessageContentRequest(); + $request->text = 'Mapped link: https://example.com/lt/abcdefghijklmnopqrstuv'; + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('atPath')->with('text')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->willReturn($builder); + + $request->validateNoClickTrackLinks($context); + } +}