From 2cb64635fff97e447508b7e189d2f480edc9cb35 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 1 Apr 2025 21:40:23 +0400 Subject: [PATCH 01/62] ISSUE-345: add validator --- src/Controller/SubscriberController.php | 73 ++++++----------------- src/Entity/SubscriberRequest.php | 22 +++++++ src/Service/Manager/SubscriberManager.php | 34 +++++++++++ src/Validator/RequestValidator.php | 31 ++++++++++ src/Validator/UniqueEmail.php | 13 ++++ src/Validator/UniqueEmailValidator.php | 35 +++++++++++ 6 files changed, 153 insertions(+), 55 deletions(-) create mode 100644 src/Entity/SubscriberRequest.php create mode 100644 src/Service/Manager/SubscriberManager.php create mode 100644 src/Validator/RequestValidator.php create mode 100644 src/Validator/UniqueEmail.php create mode 100644 src/Validator/UniqueEmailValidator.php diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 452ef42..3081f14 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -4,16 +4,16 @@ namespace PhpList\RestBundle\Controller; +use PhpList\RestBundle\Entity\SubscriberRequest; +use PhpList\RestBundle\Service\Manager\SubscriberManager; +use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\ConflictHttpException; -use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\SerializerInterface; use OpenApi\Attributes as OA; @@ -28,11 +28,16 @@ class SubscriberController extends AbstractController use AuthenticationTrait; private SubscriberRepository $subscriberRepository; + private SubscriberManager $subscriberManager; - public function __construct(Authentication $authentication, SubscriberRepository $repository) - { + public function __construct( + Authentication $authentication, + SubscriberRepository $repository, + SubscriberManager $subscriberManager, + ) { $this->authentication = $authentication; $this->subscriberRepository = $repository; + $this->subscriberManager = $subscriberManager; } #[Route('/subscribers', name: 'create_subscriber', methods: ['POST'])] @@ -126,25 +131,16 @@ public function __construct(Authentication $authentication, SubscriberRepository ) ] )] - public function postAction(Request $request, SerializerInterface $serializer): JsonResponse - { + public function postAction( + Request $request, + SerializerInterface $serializer, + RequestValidator $validator + ): JsonResponse { $this->requireAuthentication($request); - $data = $request->getPayload(); - $this->validateSubscriber($request); - - $email = $data->get('email'); - if ($this->subscriberRepository->findOneByEmail($email) !== null) { - throw new ConflictHttpException('This resource already exists.', null, 1513439108); - } - $confirmed = (bool)$data->get('request_confirmation', true); - $subscriber = new Subscriber(); - $subscriber->setEmail($email); - $subscriber->setConfirmed(!$confirmed); - $subscriber->setBlacklisted(false); - $subscriber->setHtmlEmail((bool)$data->get('html_email', true)); - $subscriber->setDisabled(false); - $this->subscriberRepository->save($subscriber); + /** @var SubscriberRequest $subscriberRequest */ + $subscriberRequest = $validator->validate($request, SubscriberRequest::class); + $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest); return new JsonResponse( $serializer->serialize($subscriber, 'json'), @@ -256,37 +252,4 @@ public function getAction(Request $request, int $subscriberId, SerializerInterfa return new JsonResponse($data, Response::HTTP_OK, [], true); } - - /** - * @param Request $request - * - * @return void - * - * @throws UnprocessableEntityHttpException - */ - private function validateSubscriber(Request $request): void - { - /** @var string[] $invalidFields */ - $invalidFields = []; - if (filter_var($request->getPayload()->get('email'), FILTER_VALIDATE_EMAIL) === false) { - $invalidFields[] = 'email'; - } - - $booleanFields = ['request_confirmation', 'html_email']; - foreach ($booleanFields as $fieldKey) { - if ($request->getPayload()->get($fieldKey) !== null - && !is_bool($request->getPayload()->get($fieldKey)) - ) { - $invalidFields[] = $fieldKey; - } - } - - if (!empty($invalidFields)) { - throw new UnprocessableEntityHttpException( - 'Some fields invalid:' . implode(', ', $invalidFields), - null, - 1513446736 - ); - } - } } diff --git a/src/Entity/SubscriberRequest.php b/src/Entity/SubscriberRequest.php new file mode 100644 index 0000000..9626488 --- /dev/null +++ b/src/Entity/SubscriberRequest.php @@ -0,0 +1,22 @@ +subscriberRepository = $subscriberRepository; + } + + public function createSubscriber(SubscriberRequest $subscriberRequest): Subscriber + { + $subscriber = new Subscriber(); + $subscriber->setEmail($subscriberRequest->email); + $confirmed = (bool)$subscriberRequest->request_confirmation; + $subscriber->setConfirmed(!$confirmed); + $subscriber->setBlacklisted(false); + $subscriber->setHtmlEmail((bool)$subscriberRequest->html_email); + $subscriber->setDisabled(false); + + $this->subscriberRepository->save($subscriber); + + return $subscriber; + } +} diff --git a/src/Validator/RequestValidator.php b/src/Validator/RequestValidator.php new file mode 100644 index 0000000..b25c9aa --- /dev/null +++ b/src/Validator/RequestValidator.php @@ -0,0 +1,31 @@ +serializer->deserialize($request->getContent(), $dtoClass, 'json'); + + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + throw new BadRequestHttpException((string) $errors); + } + + return $dto; + } +} diff --git a/src/Validator/UniqueEmail.php b/src/Validator/UniqueEmail.php new file mode 100644 index 0000000..ae2799c --- /dev/null +++ b/src/Validator/UniqueEmail.php @@ -0,0 +1,13 @@ +subscriberRepository = $subscriberRepository; + } + + public function validate($value, Constraint $constraint) + { + /* @var $constraint UniqueEmail */ + + if (null === $value || '' === $value) { + return; + } + + if ($this->subscriberRepository->findOneByEmail($value)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } +} + From ad6651ee1de9524e356dafdab5a9a3821ef6a67a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 1 Apr 2025 21:40:35 +0400 Subject: [PATCH 02/62] ISSUE-345: update configs --- config/services.yml | 13 ++++--------- config/services/listeners.yml | 13 +++++++++++++ config/services/managers.yml | 9 +++++++++ config/services/normalizers.yml | 9 +++++++++ 4 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 config/services/listeners.yml create mode 100644 config/services/managers.yml create mode 100644 config/services/normalizers.yml diff --git a/config/services.yml b/config/services.yml index 36264ec..077f3b8 100644 --- a/config/services.yml +++ b/config/services.yml @@ -1,3 +1,5 @@ +imports: + - { resource: 'services/*.yml' } services: Psr\Container\ContainerInterface: alias: 'service_container' @@ -28,13 +30,6 @@ services: autowire: true autoconfigure: true - PhpList\RestBundle\EventListener\ExceptionListener: - tags: - - { name: kernel.event_listener, event: kernel.exception } - - PhpList\RestBundle\EventListener\ResponseListener: - tags: - - { name: kernel.event_listener, event: kernel.response } - PhpList\RestBundle\Serializer\SubscriberNormalizer: - tags: [ 'serializer.normalizer' ] + PhpList\RestBundle\Validator\RequestValidator: autowire: true + autoconfigure: true diff --git a/config/services/listeners.yml b/config/services/listeners.yml new file mode 100644 index 0000000..6257282 --- /dev/null +++ b/config/services/listeners.yml @@ -0,0 +1,13 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\EventListener\ExceptionListener: + tags: + - { name: kernel.event_listener, event: kernel.exception } + + PhpList\RestBundle\EventListener\ResponseListener: + tags: + - { name: kernel.event_listener, event: kernel.response } diff --git a/config/services/managers.yml b/config/services/managers.yml new file mode 100644 index 0000000..6de11cf --- /dev/null +++ b/config/services/managers.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Service\Manager\SubscriberManager: + autowire: true + autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml new file mode 100644 index 0000000..19c6d29 --- /dev/null +++ b/config/services/normalizers.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Serializer\SubscriberNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true From a055d9dea584b56a9e87a5d5785b3e5857cbb1a5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 3 Apr 2025 22:08:14 +0400 Subject: [PATCH 03/62] ISSUE-345: refactor --- config/services.yml | 8 -------- config/services/validators.yml | 9 +++++++++ src/Controller/SubscriberController.php | 8 ++++---- src/Entity/RequestInterface.php | 10 ++++++++++ src/Entity/SubscriberRequest.php | 6 +++--- src/Validator/RequestValidator.php | 3 ++- src/Validator/UniqueEmail.php | 13 +++++++++++-- src/Validator/UniqueEmailValidator.php | 17 +++++++++++++---- 8 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 config/services/validators.yml create mode 100644 src/Entity/RequestInterface.php diff --git a/config/services.yml b/config/services.yml index 077f3b8..a126a15 100644 --- a/config/services.yml +++ b/config/services.yml @@ -10,10 +10,6 @@ services: autowire: true tags: ['controller.service_arguments'] - # Symfony\Component\Serializer\SerializerInterface: - # autowire: true - # autoconfigure: true - my.secure_handler: class: PhpList\RestBundle\ViewHandler\SecuredViewHandler @@ -29,7 +25,3 @@ services: PhpList\Core\Domain\Repository\Messaging\SubscriberListRepository: autowire: true autoconfigure: true - - PhpList\RestBundle\Validator\RequestValidator: - autowire: true - autoconfigure: true diff --git a/config/services/validators.yml b/config/services/validators.yml new file mode 100644 index 0000000..29411cb --- /dev/null +++ b/config/services/validators.yml @@ -0,0 +1,9 @@ +services: + PhpList\RestBundle\Validator\RequestValidator: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Validator\UniqueEmailValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 3081f14..5c27166 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -4,19 +4,19 @@ namespace PhpList\RestBundle\Controller; +use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; +use PhpList\Core\Security\Authentication; +use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\SubscriberRequest; use PhpList\RestBundle\Service\Manager\SubscriberManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\SerializerInterface; -use OpenApi\Attributes as OA; /** * This controller provides REST API access to subscribers. diff --git a/src/Entity/RequestInterface.php b/src/Entity/RequestInterface.php new file mode 100644 index 0000000..86b6a72 --- /dev/null +++ b/src/Entity/RequestInterface.php @@ -0,0 +1,10 @@ +serializer->deserialize($request->getContent(), $dtoClass, 'json'); diff --git a/src/Validator/UniqueEmail.php b/src/Validator/UniqueEmail.php index ae2799c..7a14b1a 100644 --- a/src/Validator/UniqueEmail.php +++ b/src/Validator/UniqueEmail.php @@ -6,8 +6,17 @@ use Symfony\Component\Validator\Constraint; -#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class UniqueEmail extends Constraint { - public string $message = 'The email "{{ value }}" is already in use.'; + public string $message = 'The email "{{ value }}" is already taken.'; + public string $mode = 'strict'; + + public function __construct(?string $mode = null, ?string $message = null, ?array $groups = null, $payload = null) + { + parent::__construct([], $groups, $payload); + + $this->mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } } diff --git a/src/Validator/UniqueEmailValidator.php b/src/Validator/UniqueEmailValidator.php index 7c6886a..ddb8e8e 100644 --- a/src/Validator/UniqueEmailValidator.php +++ b/src/Validator/UniqueEmailValidator.php @@ -7,6 +7,8 @@ use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; class UniqueEmailValidator extends ConstraintValidator { @@ -17,19 +19,26 @@ public function __construct(SubscriberRepository $subscriberRepository) $this->subscriberRepository = $subscriberRepository; } - public function validate($value, Constraint $constraint) + public function validate($value, Constraint $constraint): void { - /* @var $constraint UniqueEmail */ + if (!$constraint instanceof UniqueEmail) { + throw new UnexpectedTypeException($constraint, UniqueEmail::class); + } if (null === $value || '' === $value) { return; } - if ($this->subscriberRepository->findOneByEmail($value)) { + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + $existingUser = $this->subscriberRepository->findOneBy(['email' => $value]); + + if ($existingUser) { $this->context->buildViolation($constraint->message) ->setParameter('{{ value }}', $value) ->addViolation(); } } } - From d716a9d0616ada4f377a73d6f30e24f785dac002 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 10:56:27 +0400 Subject: [PATCH 04/62] ISSUE-345: update core --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 095b5a6..28c4171 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "v5.0.0-alpha3", + "phplist/core": "v5.0.0-alpha4", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", From 253d39decb3d028c9afaa5030d0d89c833d543b2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 11:08:01 +0400 Subject: [PATCH 05/62] ISSUE-345: return 422 --- src/Validator/RequestValidator.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Validator/RequestValidator.php b/src/Validator/RequestValidator.php index 2a8fd5a..2de552e 100644 --- a/src/Validator/RequestValidator.php +++ b/src/Validator/RequestValidator.php @@ -6,9 +6,11 @@ use PhpList\RestBundle\Entity\RequestInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Throwable; class RequestValidator { @@ -19,12 +21,19 @@ public function __construct( public function validate(Request $request, string $dtoClass): RequestInterface { - $dto = $this->serializer->deserialize($request->getContent(), $dtoClass, 'json'); - + try { + $dto = $this->serializer->deserialize( + $request->getContent(), + $dtoClass, + 'json' + ); + } catch (Throwable $e) { + throw new UnprocessableEntityHttpException('Invalid JSON: ' . $e->getMessage()); + } $errors = $this->validator->validate($dto); if (count($errors) > 0) { - throw new BadRequestHttpException((string) $errors); + throw new UnprocessableEntityHttpException((string) $errors); } return $dto; From 103ce26e515557fde0682942f590a3402df57d7a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 11:18:36 +0400 Subject: [PATCH 06/62] ISSUE-345: return 409 --- src/Validator/UniqueEmailValidator.php | 5 ++--- .../Controller/AbstractTestController.php | 12 +++++------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Validator/UniqueEmailValidator.php b/src/Validator/UniqueEmailValidator.php index ddb8e8e..2814871 100644 --- a/src/Validator/UniqueEmailValidator.php +++ b/src/Validator/UniqueEmailValidator.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Validator; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; @@ -36,9 +37,7 @@ public function validate($value, Constraint $constraint): void $existingUser = $this->subscriberRepository->findOneBy(['email' => $value]); if ($existingUser) { - $this->context->buildViolation($constraint->message) - ->setParameter('{{ value }}', $value) - ->addViolation(); + throw new ConflictHttpException('Email already exists.'); } } } diff --git a/tests/Integration/Controller/AbstractTestController.php b/tests/Integration/Controller/AbstractTestController.php index 22dc053..dfd4181 100644 --- a/tests/Integration/Controller/AbstractTestController.php +++ b/tests/Integration/Controller/AbstractTestController.php @@ -240,13 +240,11 @@ protected function assertHttpMethodNotAllowed(): void protected function assertHttpConflict(): void { $this->assertHttpStatusWithJsonContentType(Response::HTTP_CONFLICT); - - self::assertSame( - [ - 'message' => 'This resource already exists.', - ], - $this->getDecodedJsonResponseContent() - ); + $data = $this->getDecodedJsonResponseContent(); + $this->assertArrayHasKey('message', $data); + $this->assertIsString($data['message']); + $this->assertNotEmpty($data['message']); + $this->assertStringContainsString('already exists', $data['message']); } /** From b74bb837166d6d29c3445bd9cb634bbc87181c6d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 11:21:44 +0400 Subject: [PATCH 07/62] ISSUE-345: test fix --- tests/Integration/Controller/SubscriberControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Controller/SubscriberControllerTest.php b/tests/Integration/Controller/SubscriberControllerTest.php index 8c34c77..8e6a73b 100644 --- a/tests/Integration/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Controller/SubscriberControllerTest.php @@ -129,7 +129,7 @@ public function testPostSubscribersWithValidSessionKeyAssignsProvidedSubscriberD $email = 'subscriber@example.com'; $jsonData = [ 'email' => $email, - 'confirmed' => true, + 'request_confirmation' => true, 'blacklisted' => true, 'html_email' => true, 'disabled' => true, From 1f46a305f54b386505e57623c613a12663651b29 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 13:23:22 +0400 Subject: [PATCH 08/62] ISSUE-345: snake to camel config --- config/services/normalizers.yml | 8 ++++++++ src/Entity/SubscriberRequest.php | 4 ++-- src/Service/Manager/SubscriberManager.php | 4 ++-- src/Validator/RequestValidator.php | 17 ++++++++++++++--- .../Controller/SubscriberControllerTest.php | 12 ++++++------ 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 19c6d29..007bed4 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -4,6 +4,14 @@ services: autoconfigure: true public: false + Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter: ~ + + Symfony\Component\Serializer\Normalizer\ObjectNormalizer: + arguments: + $classMetadataFactory: '@?serializer.mapping.class_metadata_factory' + $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter' +# $propertyTypeExtractor: '@?serializer.property_type_extractor' + PhpList\RestBundle\Serializer\SubscriberNormalizer: tags: [ 'serializer.normalizer' ] autowire: true diff --git a/src/Entity/SubscriberRequest.php b/src/Entity/SubscriberRequest.php index 161b65b..e202acb 100644 --- a/src/Entity/SubscriberRequest.php +++ b/src/Entity/SubscriberRequest.php @@ -15,8 +15,8 @@ class SubscriberRequest implements RequestInterface public string $email; #[Assert\Type(type: 'bool')] - public ?bool $request_confirmation = null; + public ?bool $requestConfirmation = null; #[Assert\Type(type: 'bool')] - public ?bool $html_email = null; + public ?bool $htmlEmail = null; } diff --git a/src/Service/Manager/SubscriberManager.php b/src/Service/Manager/SubscriberManager.php index 1451845..aeedc58 100644 --- a/src/Service/Manager/SubscriberManager.php +++ b/src/Service/Manager/SubscriberManager.php @@ -21,10 +21,10 @@ public function createSubscriber(SubscriberRequest $subscriberRequest): Subscrib { $subscriber = new Subscriber(); $subscriber->setEmail($subscriberRequest->email); - $confirmed = (bool)$subscriberRequest->request_confirmation; + $confirmed = (bool)$subscriberRequest->requestConfirmation; $subscriber->setConfirmed(!$confirmed); $subscriber->setBlacklisted(false); - $subscriber->setHtmlEmail((bool)$subscriberRequest->html_email); + $subscriber->setHtmlEmail((bool)$subscriberRequest->htmlEmail); $subscriber->setDisabled(false); $this->subscriberRepository->save($subscriber); diff --git a/src/Validator/RequestValidator.php b/src/Validator/RequestValidator.php index 2de552e..d0689e2 100644 --- a/src/Validator/RequestValidator.php +++ b/src/Validator/RequestValidator.php @@ -9,7 +9,6 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Throwable; class RequestValidator @@ -17,7 +16,8 @@ class RequestValidator public function __construct( private readonly SerializerInterface $serializer, private readonly ValidatorInterface $validator - ) {} + ) { + } public function validate(Request $request, string $dtoClass): RequestInterface { @@ -33,7 +33,18 @@ public function validate(Request $request, string $dtoClass): RequestInterface $errors = $this->validator->validate($dto); if (count($errors) > 0) { - throw new UnprocessableEntityHttpException((string) $errors); + $lines = []; + foreach ($errors as $violation) { + $lines[] = sprintf( + '%s: %s', + $violation->getPropertyPath(), + $violation->getMessage() + ); + } + + $message = implode("\n", $lines); + + throw new UnprocessableEntityHttpException($message); } return $dto; diff --git a/tests/Integration/Controller/SubscriberControllerTest.php b/tests/Integration/Controller/SubscriberControllerTest.php index 8e6a73b..3cd37cd 100644 --- a/tests/Integration/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Controller/SubscriberControllerTest.php @@ -106,10 +106,10 @@ public static function invalidSubscriberDataProvider(): array 'email is an empty string' => [['email' => '']], 'email is invalid string' => [['email' => 'coffee and cigarettes']], 'email as boolean' => [['email' => true]], - 'html_email as integer' => [['email' => 'kate@example.com', 'html_email' => 1]], - 'html_email as string' => [['email' => 'kate@example.com', 'html_email' => 'yes']], - 'request_confirmation as string' => [['email' => 'kate@example.com', 'request_confirmation' => 'needed']], - 'disabled as string' => [['email' => 'kate@example.com', 'request_confirmation' => 1]], + 'html_email as integer' => [['email' => 'kate@example.com', 'htmlEmail' => 1]], + 'html_email as string' => [['email' => 'kate@example.com', 'htmlEmail' => 'yes']], + 'request_confirmation as string' => [['email' => 'kate@example.com', 'requestConfirmation' => 'needed']], + 'disabled as string' => [['email' => 'kate@example.com', 'requestConfirmation' => 1]], ]; } @@ -129,9 +129,9 @@ public function testPostSubscribersWithValidSessionKeyAssignsProvidedSubscriberD $email = 'subscriber@example.com'; $jsonData = [ 'email' => $email, - 'request_confirmation' => true, + 'requestConfirmation' => true, 'blacklisted' => true, - 'html_email' => true, + 'htmlEmail' => true, 'disabled' => true, ]; From 4c9892354e0dab236ce0427c35897220a75086d5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 14:31:08 +0400 Subject: [PATCH 09/62] ISSUE-345: add tests --- tests/Helpers/DummyRequestDto.php | 12 ++ .../Service/Manager/SubscriberManagerTest.php | 45 +++++++ .../Validator/RequestValidatorTest.php | 114 ++++++++++++++++++ .../Validator/UniqueEmailValidatorTest.php | 77 ++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 tests/Helpers/DummyRequestDto.php create mode 100644 tests/Integration/Service/Manager/SubscriberManagerTest.php create mode 100644 tests/Integration/Validator/RequestValidatorTest.php create mode 100644 tests/Integration/Validator/UniqueEmailValidatorTest.php diff --git a/tests/Helpers/DummyRequestDto.php b/tests/Helpers/DummyRequestDto.php new file mode 100644 index 0000000..11e45f7 --- /dev/null +++ b/tests/Helpers/DummyRequestDto.php @@ -0,0 +1,12 @@ +createMock(SubscriberRepository::class); + $repoMock + ->expects($this->once()) + ->method('save') + ->with($this->callback(function (Subscriber $sub): bool { + return $sub->getEmail() === 'foo@bar.com' + && $sub->isConfirmed() === false + && $sub->isBlacklisted() === false + && $sub->hasHtmlEmail() === true + && $sub->isDisabled() === false; + })); + + $manager = new SubscriberManager($repoMock); + + $dto = new SubscriberRequest(); + $dto->email = 'foo@bar.com'; + $dto->requestConfirmation = true; + $dto->htmlEmail = true; + + $result = $manager->createSubscriber($dto); + + $this->assertInstanceOf(Subscriber::class, $result); + $this->assertSame('foo@bar.com', $result->getEmail()); + $this->assertFalse($result->isConfirmed()); + $this->assertFalse($result->isBlacklisted()); + $this->assertTrue($result->hasHtmlEmail()); + $this->assertFalse($result->isDisabled()); + } +} diff --git a/tests/Integration/Validator/RequestValidatorTest.php b/tests/Integration/Validator/RequestValidatorTest.php new file mode 100644 index 0000000..df7d42f --- /dev/null +++ b/tests/Integration/Validator/RequestValidatorTest.php @@ -0,0 +1,114 @@ +serializer = $this->createMock(SerializerInterface::class); + $this->validator = $this->createMock(ValidatorInterface::class); + $this->requestValidator = new RequestValidator( + $this->serializer, + $this->validator + ); + } + + public function testValidateReturnsDtoWhenJsonValidAndNoViolations(): void + { + $dto = $this->createMock(RequestInterface::class); + $json = '{"foo":"bar"}'; + + $this->serializer + ->expects(self::once()) + ->method('deserialize') + ->with($json, DummyRequestDto::class, 'json') + ->willReturn($dto); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->with($dto) + ->willReturn(new ConstraintViolationList()); + + $request = new Request([], [], [], [], [], [], $json); + + $result = $this->requestValidator->validate($request, DummyRequestDto::class); + self::assertSame($dto, $result); + } + + public function testValidateThrowsOnInvalidJson(): void + { + $json = '{ invalid json }'; + $request = new Request([], [], [], [], [], [], $json); + + $this->serializer + ->expects(self::once()) + ->method('deserialize') + ->willThrowException(new RuntimeException('Syntax error')); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->expectExceptionMessage('Invalid JSON: Syntax error'); + + $this->requestValidator->validate($request, DummyRequestDto::class); + } + + public function testValidateThrowsOnConstraintViolations(): void + { + $dto = $this->createMock(RequestInterface::class); + $json = '{"email":"bad"}'; + $request = new Request([], [], [], [], [], [], $json); + + $this->serializer + ->method('deserialize') + ->willReturn($dto); + + $violation1 = new ConstraintViolation( + 'Must not be blank', + '', + [], + null, + 'email', + '' + ); + $violation2 = new ConstraintViolation( + 'Must be a valid email', + '', + [], + null, + 'email', + 'bad' + ); + $violations = new ConstraintViolationList([$violation1, $violation2]); + + $this->validator + ->method('validate') + ->with($dto) + ->willReturn($violations); + + $this->expectException(UnprocessableEntityHttpException::class); + + $this->expectExceptionMessage("email: Must not be blank\nemail: Must be a valid email"); + + $this->requestValidator->validate($request, DummyRequestDto::class); + } +} diff --git a/tests/Integration/Validator/UniqueEmailValidatorTest.php b/tests/Integration/Validator/UniqueEmailValidatorTest.php new file mode 100644 index 0000000..069ee8a --- /dev/null +++ b/tests/Integration/Validator/UniqueEmailValidatorTest.php @@ -0,0 +1,77 @@ +repo = $this->createMock(SubscriberRepository::class); + $this->validator = new UniqueEmailValidator($this->repo); + } + + public function testThrowsUnexpectedTypeExceptionWhenConstraintIsWrong(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate('anything', $this->createMock(Constraint::class)); + } + + public function testSkipsValidationForNullOrEmpty(): void + { + $this->repo->expects(self::never())->method('findOneBy'); + + $this->validator->validate(null, new UniqueEmail()); + $this->validator->validate('', new UniqueEmail()); + + $this->addToAssertionCount(1); + } + + public function testThrowsUnexpectedValueExceptionForNonString(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(123, new UniqueEmail()); + } + + public function testThrowsConflictHttpExceptionWhenEmailAlreadyExists(): void + { + $email = 'foo@bar.com'; + + $this->repo + ->expects(self::once()) + ->method('findOneBy') + ->with(['email' => $email]) + ->willReturn((object)['email' => $email]); + + $this->expectException(ConflictHttpException::class); + $this->expectExceptionMessage('Email already exists.'); + + $this->validator->validate($email, new UniqueEmail()); + } + + public function testAllowsUniqueEmailWhenNoExistingSubscriber(): void + { + $this->repo + ->expects(self::once()) + ->method('findOneBy') + ->willReturn(null); + + $this->validator->validate('new@example.com', new UniqueEmail()); + + $this->addToAssertionCount(1); + } +} From b5f1db2088113341e7174807e2f93e40da9abbfb Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 17:45:35 +0400 Subject: [PATCH 10/62] ISSUE-345: use swagger schemas --- config/services/normalizers.yml | 1 - src/Controller/ListController.php | 43 +--------- src/Controller/SubscriberController.php | 86 +++---------------- ...equest.php => CreateSubscriberRequest.php} | 2 +- src/OpenApi/SwaggerSchemas.php | 40 +++++++++ src/Service/Manager/SubscriberManager.php | 4 +- .../Service/Manager/SubscriberManagerTest.php | 4 +- 7 files changed, 60 insertions(+), 120 deletions(-) rename src/Entity/{SubscriberRequest.php => CreateSubscriberRequest.php} (88%) create mode 100644 src/OpenApi/SwaggerSchemas.php diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 007bed4..ce94fba 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -10,7 +10,6 @@ services: arguments: $classMetadataFactory: '@?serializer.mapping.class_metadata_factory' $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter' -# $propertyTypeExtractor: '@?serializer.property_type_extractor' PhpList\RestBundle\Serializer\SubscriberNormalizer: tags: [ 'serializer.normalizer' ] diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index a09dfb1..acea76a 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -298,48 +298,7 @@ public function deleteList( description: 'Success', content: new OA\JsonContent( type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'id', type: 'integer', example: 1), - new OA\Property(property: 'email', type: 'string', example: 'subscriber@example.com'), - new OA\Property( - property: 'creation_date', - type: 'string', - format: 'date-time', - example: '2023-01-01T12:00:00Z' - ), - new OA\Property(property: 'confirmed', type: 'boolean', example: true), - new OA\Property(property: 'blacklisted', type: 'boolean', example: false), - new OA\Property(property: 'bounce_count', type: 'integer', example: 0), - new OA\Property(property: 'unique_id', type: 'string', example: 'abc123'), - new OA\Property(property: 'html_email', type: 'boolean', example: true), - new OA\Property(property: 'disabled', type: 'boolean', example: false), - new OA\Property( - property: 'subscribedLists', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'id', type: 'integer', example: 2), - new OA\Property(property: 'name', type: 'string', example: 'Newsletter'), - new OA\Property( - property: 'description', - type: 'string', - example: 'Monthly updates' - ), - new OA\Property( - property: 'creation_date', - type: 'string', - format: 'date-time', - example: '2022-12-01T10:00:00Z' - ), - new OA\Property(property: 'public', type: 'boolean', example: true), - ], - type: 'object' - ) - ), - ], - type: 'object' - ) + items: new OA\Items(ref: '#/components/schemas/Subscriber') ) ), new OA\Response( diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 5c27166..eec9561 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -5,10 +5,12 @@ namespace PhpList\RestBundle\Controller; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; -use PhpList\RestBundle\Entity\SubscriberRequest; +use PhpList\RestBundle\Entity\CreateSubscriberRequest; +use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Service\Manager\SubscriberManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -71,28 +73,7 @@ public function __construct( new OA\Response( response: 201, description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'creation_date', - type: 'string', - format: 'date-time', - example: '2017-12-16T18:44:27+00:00' - ), - new OA\Property(property: 'email', type: 'string', example: 'subscriber@example.com'), - new OA\Property(property: 'confirmed', type: 'boolean', example: false), - new OA\Property(property: 'blacklisted', type: 'boolean', example: false), - new OA\Property(property: 'bounced', type: 'integer', example: 0), - new OA\Property( - property: 'unique_id', - type: 'string', - example: '69f4e92cf50eafca9627f35704f030f4' - ), - new OA\Property(property: 'html_email', type: 'boolean', example: false), - new OA\Property(property: 'disabled', type: 'boolean', example: false), - new OA\Property(property: 'id', type: 'integer', example: 1) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/Subscriber'), ), new OA\Response( response: 403, @@ -138,8 +119,8 @@ public function postAction( ): JsonResponse { $this->requireAuthentication($request); - /** @var SubscriberRequest $subscriberRequest */ - $subscriberRequest = $validator->validate($request, SubscriberRequest::class); + /** @var CreateSubscriberRequest $subscriberRequest */ + $subscriberRequest = $validator->validate($request, CreateSubscriberRequest::class); $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest); return new JsonResponse( @@ -176,48 +157,7 @@ public function postAction( new OA\Response( response: 200, description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'id', type: 'integer', example: 1), - new OA\Property(property: 'email', type: 'string', example: 'subscriber@example.com'), - new OA\Property( - property: 'creation_date', - type: 'string', - format: 'date-time', - example: '2023-01-01T12:00:00Z' - ), - new OA\Property(property: 'confirmed', type: 'boolean', example: true), - new OA\Property(property: 'blacklisted', type: 'boolean', example: false), - new OA\Property(property: 'bounce_count', type: 'integer', example: 0), - new OA\Property(property: 'unique_id', type: 'string', example: 'abc123'), - new OA\Property(property: 'html_email', type: 'boolean', example: true), - new OA\Property(property: 'disabled', type: 'boolean', example: false), - new OA\Property( - property: 'subscribedLists', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'id', type: 'integer', example: 2), - new OA\Property(property: 'name', type: 'string', example: 'Newsletter'), - new OA\Property( - property: 'description', - type: 'string', - example: 'Monthly updates' - ), - new OA\Property( - property: 'creation_date', - type: 'string', - format: 'date-time', - example: '2022-12-01T10:00:00Z' - ), - new OA\Property(property: 'public', type: 'boolean', example: true), - ], - type: 'object' - ) - ), - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/Subscriber'), ), new OA\Response( response: 403, @@ -238,18 +178,20 @@ public function postAction( ) ] )] - public function getAction(Request $request, int $subscriberId, SerializerInterface $serializer): JsonResponse + public function getSubscriber(Request $request, int $subscriberId, SubscriberNormalizer $serializer): JsonResponse { $this->requireAuthentication($request); $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); - if (!$subscriber) { return new JsonResponse(['error' => 'Subscriber not found'], Response::HTTP_NOT_FOUND); } - $data = $serializer->serialize($subscriber, 'json'); - - return new JsonResponse($data, Response::HTTP_OK, [], true); + return new JsonResponse( + $serializer->normalize($subscriber, 'json'), + Response::HTTP_OK, + [], + false + ); } } diff --git a/src/Entity/SubscriberRequest.php b/src/Entity/CreateSubscriberRequest.php similarity index 88% rename from src/Entity/SubscriberRequest.php rename to src/Entity/CreateSubscriberRequest.php index e202acb..000a425 100644 --- a/src/Entity/SubscriberRequest.php +++ b/src/Entity/CreateSubscriberRequest.php @@ -7,7 +7,7 @@ use PhpList\RestBundle\Validator as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; -class SubscriberRequest implements RequestInterface +class CreateSubscriberRequest implements RequestInterface { #[Assert\NotBlank] #[Assert\Email] diff --git a/src/OpenApi/SwaggerSchemas.php b/src/OpenApi/SwaggerSchemas.php new file mode 100644 index 0000000..66de680 --- /dev/null +++ b/src/OpenApi/SwaggerSchemas.php @@ -0,0 +1,40 @@ +subscriberRepository = $subscriberRepository; } - public function createSubscriber(SubscriberRequest $subscriberRequest): Subscriber + public function createSubscriber(CreateSubscriberRequest $subscriberRequest): Subscriber { $subscriber = new Subscriber(); $subscriber->setEmail($subscriberRequest->email); diff --git a/tests/Integration/Service/Manager/SubscriberManagerTest.php b/tests/Integration/Service/Manager/SubscriberManagerTest.php index 27b0c1a..a2612a9 100644 --- a/tests/Integration/Service/Manager/SubscriberManagerTest.php +++ b/tests/Integration/Service/Manager/SubscriberManagerTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase; use PhpList\RestBundle\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\RestBundle\Entity\SubscriberRequest; +use PhpList\RestBundle\Entity\CreateSubscriberRequest; use PhpList\Core\Domain\Model\Subscription\Subscriber; class SubscriberManagerTest extends TestCase @@ -28,7 +28,7 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity( $manager = new SubscriberManager($repoMock); - $dto = new SubscriberRequest(); + $dto = new CreateSubscriberRequest(); $dto->email = 'foo@bar.com'; $dto->requestConfirmation = true; $dto->htmlEmail = true; From 5e5832018c76363abcec8200e2a8d2710c9117df Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 5 Apr 2025 21:44:18 +0400 Subject: [PATCH 11/62] ISSUE-345: update subscriber --- src/Controller/SubscriberController.php | 103 ++++++++++++++++++++-- src/Entity/UpdateSubscriberRequest.php | 45 ++++++++++ src/Service/Manager/SubscriberManager.php | 35 +++++++- src/Validator/RequestValidator.php | 10 ++- src/Validator/UniqueEmailValidator.php | 5 +- 5 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 src/Entity/UpdateSubscriberRequest.php diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index eec9561..c92c8af 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -10,9 +10,11 @@ use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\CreateSubscriberRequest; +use PhpList\RestBundle\Entity\UpdateSubscriberRequest; use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Service\Manager\SubscriberManager; use PhpList\RestBundle\Validator\RequestValidator; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -24,7 +26,9 @@ * This controller provides REST API access to subscribers. * * @author Oliver Klee + * @author Tatevik Grigoryan */ +#[Route('/subscribers')] class SubscriberController extends AbstractController { use AuthenticationTrait; @@ -42,7 +46,7 @@ public function __construct( $this->subscriberManager = $subscriberManager; } - #[Route('/subscribers', name: 'create_subscriber', methods: ['POST'])] + #[Route('', name: 'create_subscriber', methods: ['POST'])] #[OA\Post( path: '/subscribers', description: 'Creates a new subscriber (if there is no subscriber with the given email address yet).', @@ -112,7 +116,7 @@ public function __construct( ) ] )] - public function postAction( + public function createSubscriber( Request $request, SerializerInterface $serializer, RequestValidator $validator @@ -131,10 +135,96 @@ public function postAction( ); } - #[Route('/subscribers/{subscriberId}', name: 'get_subscriber_by_id', methods: ['GET'])] + #[Route('/{subscriberId}', name: 'update_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['PUT'])] #[OA\Get( path: '/subscribers/{subscriberId}', - description: 'Get subscriber date by id.', + description: 'Update subscriber data by id.', + summary: 'Update subscriber', + requestBody: new OA\RequestBody( + description: 'Pass session credentials', + required: true, + content: new OA\JsonContent( + required: ['email'], + properties: [ + new OA\Property(property: 'email', type: 'string', format: 'string', example: 'admin@example.com'), + new OA\Property(property: 'html_email', type: 'boolean', example: false), + new OA\Property(property: 'confirmed', type: 'boolean', example: false), + new OA\Property(property: 'blacklisted', type: 'boolean', example: false), + new OA\Property(property: 'html_email', type: 'boolean', example: false), + new OA\Property(property: 'disabled', type: 'boolean', example: false), + new OA\Property(property: 'additional_data', type: 'string', example: 'asdf'), + ] + ) + ), + tags: ['subscribers'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'subscriberId', + description: 'Subscriber 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/Subscriber'), + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'No valid session key was provided as basic auth password.' + ) + ] + ) + ), + new OA\Response( + response: 404, + description: 'Not Found', + ) + ] + )] + public function update( + Request $request, + #[MapEntity(mapping: ['subscriberId' => 'id'])] Subscriber $subscriber, + SerializerInterface $serializer, + RequestValidator $validator, + SubscriberNormalizer $subscriberNormalizer, + ): JsonResponse { + $this->requireAuthentication($request); + + /** @var UpdateSubscriberRequest $dto */ + $dto = $serializer->deserialize($request->getContent(), UpdateSubscriberRequest::class, 'json'); + $dto->subscriberId = $subscriber->getId(); + $validator->validateDto($dto); + $subscriber = $this->subscriberManager->updateSubscriber($dto); + + return new JsonResponse( + $subscriberNormalizer->normalize($subscriber, 'json'), + Response::HTTP_OK, + [], + false + ); + } + + #[Route('/{subscriberId}', name: 'get_subscriber_by_id', methods: ['GET'])] + #[OA\Get( + path: '/subscribers/{subscriberId}', + description: 'Get subscriber data by id.', summary: 'Get a subscriber', tags: ['subscribers'], parameters: [ @@ -182,10 +272,7 @@ public function getSubscriber(Request $request, int $subscriberId, SubscriberNor { $this->requireAuthentication($request); - $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); - if (!$subscriber) { - return new JsonResponse(['error' => 'Subscriber not found'], Response::HTTP_NOT_FOUND); - } + $subscriber = $this->subscriberManager->getSubscriber($subscriberId); return new JsonResponse( $serializer->normalize($subscriber, 'json'), diff --git a/src/Entity/UpdateSubscriberRequest.php b/src/Entity/UpdateSubscriberRequest.php new file mode 100644 index 0000000..09e7af7 --- /dev/null +++ b/src/Entity/UpdateSubscriberRequest.php @@ -0,0 +1,45 @@ +subscriberRepository = $subscriberRepository; + $this->entityManager = $entityManager; } public function createSubscriber(CreateSubscriberRequest $subscriberRequest): Subscriber @@ -31,4 +36,32 @@ public function createSubscriber(CreateSubscriberRequest $subscriberRequest): Su return $subscriber; } + + public function getSubscriber(int $subscriberId): Subscriber + { + $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); + + if (!$subscriber) { + throw new NotFoundHttpException('Subscriber not found'); + } + + return $subscriber; + } + + public function updateSubscriber(UpdateSubscriberRequest $subscriberRequest): Subscriber + { + /** @var Subscriber $subscriber */ + $subscriber = $this->subscriberRepository->find($subscriberRequest->subscriberId); + + $subscriber->setEmail($subscriberRequest->email); + $subscriber->setConfirmed($subscriberRequest->confirmed); + $subscriber->setBlacklisted($subscriberRequest->blacklisted); + $subscriber->setHtmlEmail($subscriberRequest->htmlEmail); + $subscriber->setDisabled($subscriberRequest->disabled); + $subscriber->setExtraData($subscriberRequest->additionalData); + + $this->entityManager->flush(); + + return $subscriber; + } } diff --git a/src/Validator/RequestValidator.php b/src/Validator/RequestValidator.php index d0689e2..ff7b62f 100644 --- a/src/Validator/RequestValidator.php +++ b/src/Validator/RequestValidator.php @@ -30,7 +30,13 @@ public function validate(Request $request, string $dtoClass): RequestInterface } catch (Throwable $e) { throw new UnprocessableEntityHttpException('Invalid JSON: ' . $e->getMessage()); } - $errors = $this->validator->validate($dto); + + return $this->validateDto($dto); + } + + public function validateDto(RequestInterface $request): RequestInterface + { + $errors = $this->validator->validate($request); if (count($errors) > 0) { $lines = []; @@ -47,6 +53,6 @@ public function validate(Request $request, string $dtoClass): RequestInterface throw new UnprocessableEntityHttpException($message); } - return $dto; + return $request; } } diff --git a/src/Validator/UniqueEmailValidator.php b/src/Validator/UniqueEmailValidator.php index 2814871..63f68dc 100644 --- a/src/Validator/UniqueEmailValidator.php +++ b/src/Validator/UniqueEmailValidator.php @@ -36,7 +36,10 @@ public function validate($value, Constraint $constraint): void $existingUser = $this->subscriberRepository->findOneBy(['email' => $value]); - if ($existingUser) { + $dto = $this->context->getObject(); + $updatingId = $dto->subscriberId ?? null; + + if ($existingUser && $existingUser->getId() !== $updatingId) { throw new ConflictHttpException('Email already exists.'); } } From 90fe1d1f09901d050f44d2b4648bff8c3397b084 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 6 Apr 2025 15:41:48 +0400 Subject: [PATCH 12/62] ISSUE-345: refactor session controller --- config/services/managers.yml | 4 + config/services/normalizers.yml | 4 + src/Controller/SessionController.php | 89 +++++-------------- src/Entity/CreateSessionRequest.php | 18 ++++ .../AdministratorTokenNormalizer.php | 32 +++++++ src/Service/Manager/SessionManager.php | 49 ++++++++++ 6 files changed, 131 insertions(+), 65 deletions(-) create mode 100644 src/Entity/CreateSessionRequest.php create mode 100644 src/Serializer/AdministratorTokenNormalizer.php create mode 100644 src/Service/Manager/SessionManager.php diff --git a/config/services/managers.yml b/config/services/managers.yml index 6de11cf..d79363d 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -7,3 +7,7 @@ services: PhpList\RestBundle\Service\Manager\SubscriberManager: autowire: true autoconfigure: true + + PhpList\RestBundle\Service\Manager\SessionManager: + autowire: true + autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index ce94fba..25f127f 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -14,3 +14,7 @@ services: PhpList\RestBundle\Serializer\SubscriberNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Serializer\AdministratorTokenNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index 4509e67..3436245 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -4,20 +4,20 @@ namespace PhpList\RestBundle\Controller; +use PhpList\RestBundle\Entity\CreateSessionRequest; +use PhpList\RestBundle\Serializer\AdministratorTokenNormalizer; +use PhpList\RestBundle\Service\Manager\SessionManager; +use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Model\Identity\Administrator; use PhpList\Core\Domain\Model\Identity\AdministratorToken; use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; -use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\SerializerInterface; use OpenApi\Attributes as OA; @@ -26,33 +26,30 @@ * This controller provides methods to create and destroy REST API sessions. * * @author Oliver Klee + * @author Tatevik Grigoryan */ +#[Route('/sessions')] class SessionController extends AbstractController { use AuthenticationTrait; private AdministratorRepository $administratorRepository; - private AdministratorTokenRepository $tokenRepository; private SerializerInterface $serializer; + private SessionManager $sessionManager; public function __construct( Authentication $authentication, AdministratorRepository $administratorRepository, - AdministratorTokenRepository $tokenRepository, - SerializerInterface $serializer + SerializerInterface $serializer, + SessionManager $sessionManager, ) { $this->authentication = $authentication; $this->administratorRepository = $administratorRepository; - $this->tokenRepository = $tokenRepository; $this->serializer = $serializer; + $this->sessionManager = $sessionManager; } - /** - * Creates a new session (if the provided credentials are valid). - * - * @throws UnauthorizedHttpException - */ - #[Route('/sessions', name: 'create_session', methods: ['POST'])] + #[Route('', name: 'create_session', methods: ['POST'])] #[OA\Post( path: '/sessions', description: 'Given valid login data, this will generate a login token that will be valid for 1 hour.', @@ -105,21 +102,18 @@ public function __construct( ) ] )] - public function createSession(Request $request): JsonResponse - { - $this->validateCreateRequest($request); - $administrator = $this->administratorRepository->findOneByLoginCredentials( - $request->getPayload()->get('login_name'), - $request->getPayload()->get('password') - ); - if ($administrator === null) { - throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098); - } + public function createSession( + Request $request, + RequestValidator $validator, + AdministratorTokenNormalizer $normalizer + ): JsonResponse { + /** @var CreateSessionRequest $createSessionRequest */ + $createSessionRequest = $validator->validate($request, CreateSessionRequest::class); + $token = $this->sessionManager->createSession($createSessionRequest); - $token = $this->createAndPersistToken($administrator); - $json = $this->serializer->serialize($token, 'json'); + $json = $normalizer->normalize($token, 'json'); - return new JsonResponse($json, Response::HTTP_CREATED, [], true); + return new JsonResponse($json, Response::HTTP_CREATED, [], false); } /** @@ -129,7 +123,7 @@ public function createSession(Request $request): JsonResponse * * @throws AccessDeniedHttpException */ - #[Route('/sessions/{sessionId}', name: 'delete_session', methods: ['DELETE'])] + #[Route('/{sessionId}', name: 'delete_session', methods: ['DELETE'])] #[OA\Delete( path: '/sessions/{sessionId}', description: 'Delete the session passed as a parameter.', @@ -177,7 +171,7 @@ public function createSession(Request $request): JsonResponse ) ] )] - public function deleteAction( + public function deleteSession( Request $request, #[MapEntity(mapping: ['sessionId' => 'id'])] AdministratorToken $token ): JsonResponse { @@ -186,43 +180,8 @@ public function deleteAction( throw new AccessDeniedHttpException('You do not have access to this session.', null, 1519831644); } - $this->tokenRepository->remove($token); + $this->sessionManager->deleteSession($token); return new JsonResponse(null, Response::HTTP_NO_CONTENT, [], false); } - - /** - * Validates the request. If is it not valid, throws an exception. - * - * @param Request $request - * - * @return void - * - * @throws BadRequestHttpException - */ - private function validateCreateRequest(Request $request): void - { - if ($request->getContent() === '') { - throw new BadRequestHttpException('Empty JSON data', null, 1500559729); - } - if (empty($request->getPayload()->get('login_name')) || empty($request->getPayload()->get('password'))) { - throw new BadRequestHttpException('Incomplete credentials', null, 1500562647); - } - } - - /** - * @param Administrator $administrator - * - * @return AdministratorToken - */ - private function createAndPersistToken(Administrator $administrator): AdministratorToken - { - $token = new AdministratorToken(); - $token->setAdministrator($administrator); - $token->generateExpiry(); - $token->generateKey(); - $this->tokenRepository->save($token); - - return $token; - } } diff --git a/src/Entity/CreateSessionRequest.php b/src/Entity/CreateSessionRequest.php new file mode 100644 index 0000000..e97768b --- /dev/null +++ b/src/Entity/CreateSessionRequest.php @@ -0,0 +1,18 @@ + $object->getId(), + 'key' => $object->getKey(), + 'expiry' => $object->getExpiry()->format('Y-m-d\TH:i:sP'), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof AdministratorToken; + } +} diff --git a/src/Service/Manager/SessionManager.php b/src/Service/Manager/SessionManager.php new file mode 100644 index 0000000..05339f8 --- /dev/null +++ b/src/Service/Manager/SessionManager.php @@ -0,0 +1,49 @@ +tokenRepository = $tokenRepository; + $this->administratorRepository = $administratorRepository; + } + + public function createSession(CreateSessionRequest $createSessionRequest): AdministratorToken + { + $administrator = $this->administratorRepository->findOneByLoginCredentials( + $createSessionRequest->loginName, + $createSessionRequest->password + ); + if ($administrator === null) { + throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098); + } + + $token = new AdministratorToken(); + $token->setAdministrator($administrator); + $token->generateExpiry(); + $token->generateKey(); + $this->tokenRepository->save($token); + + return $token; + } + + public function deleteSession(AdministratorToken $token): void + { + $this->tokenRepository->remove($token); + } +} From a553700b5472b1ed1e93379bfff680e86741e1b0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 6 Apr 2025 16:43:07 +0400 Subject: [PATCH 13/62] ISSUE-345: test fix --- src/Controller/SessionController.php | 14 +--- src/Controller/SubscriberController.php | 10 +-- src/Entity/UpdateSubscriberRequest.php | 12 ---- src/OpenApi/SwaggerSchemas.php | 18 ++++- .../AdministratorTokenNormalizer.php | 5 +- src/Validator/RequestValidator.php | 3 +- .../Controller/SessionControllerTest.php | 37 ++++------ .../Controller/SubscriberControllerTest.php | 37 +++++++--- .../Service/Manager/SubscriberManagerTest.php | 4 +- .../Validator/RequestValidatorTest.php | 3 +- .../Validator/UniqueEmailValidatorTest.php | 67 ++++++++++++++++--- .../Controller/SessionControllerTest.php | 2 +- 12 files changed, 132 insertions(+), 80 deletions(-) diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index 3436245..23f4e10 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -11,7 +11,6 @@ use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use PhpList\Core\Domain\Model\Identity\AdministratorToken; -use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; @@ -19,7 +18,6 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; use OpenApi\Attributes as OA; /** @@ -33,19 +31,11 @@ class SessionController extends AbstractController { use AuthenticationTrait; - private AdministratorRepository $administratorRepository; - private SerializerInterface $serializer; private SessionManager $sessionManager; - public function __construct( - Authentication $authentication, - AdministratorRepository $administratorRepository, - SerializerInterface $serializer, - SessionManager $sessionManager, - ) { + public function __construct(Authentication $authentication, SessionManager $sessionManager) + { $this->authentication = $authentication; - $this->administratorRepository = $administratorRepository; - $this->serializer = $serializer; $this->sessionManager = $sessionManager; } diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index c92c8af..dd0cca1 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -6,7 +6,6 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Subscription\Subscriber; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\CreateSubscriberRequest; @@ -33,16 +32,11 @@ class SubscriberController extends AbstractController { use AuthenticationTrait; - private SubscriberRepository $subscriberRepository; private SubscriberManager $subscriberManager; - public function __construct( - Authentication $authentication, - SubscriberRepository $repository, - SubscriberManager $subscriberManager, - ) { + public function __construct(Authentication $authentication, SubscriberManager $subscriberManager) + { $this->authentication = $authentication; - $this->subscriberRepository = $repository; $this->subscriberManager = $subscriberManager; } diff --git a/src/Entity/UpdateSubscriberRequest.php b/src/Entity/UpdateSubscriberRequest.php index 09e7af7..a895966 100644 --- a/src/Entity/UpdateSubscriberRequest.php +++ b/src/Entity/UpdateSubscriberRequest.php @@ -25,21 +25,9 @@ class UpdateSubscriberRequest implements RequestInterface #[Assert\Type(type: 'bool')] public bool $htmlEmail; - #[Assert\Type(type: 'number')] - public ?int $rssFrequency = null; // todo check what is this - #[Assert\Type(type: 'bool')] public bool $disabled; #[Assert\Type(type: 'string')] public string $additionalData; - - #[Assert\Type(type: 'string')] - public ?string $woonplaats = null; // todo check what is this - - #[Assert\Type(type: 'string')] - public ?string $foreignKey = null; // todo check what is this - - #[Assert\Type(type: 'string')] - public ?string $country = null; // todo check what is this } diff --git a/src/OpenApi/SwaggerSchemas.php b/src/OpenApi/SwaggerSchemas.php index 66de680..ed93d67 100644 --- a/src/OpenApi/SwaggerSchemas.php +++ b/src/OpenApi/SwaggerSchemas.php @@ -12,7 +12,12 @@ new OA\Property(property: 'id', type: 'integer', example: 2), new OA\Property(property: 'name', type: 'string', example: 'Newsletter'), new OA\Property(property: 'description', type: 'string', example: 'Monthly updates'), - new OA\Property(property: 'creation_date', type: 'string', format: 'date-time', example: '2022-12-01T10:00:00Z'), + new OA\Property( + property: 'creation_date', + type: 'string', + format: 'date-time', + example: '2022-12-01T10:00:00Z' + ), new OA\Property(property: 'public', type: 'boolean', example: true), ], type: 'object' @@ -22,7 +27,12 @@ properties: [ new OA\Property(property: 'id', type: 'integer', example: 1), new OA\Property(property: 'email', type: 'string', example: 'subscriber@example.com'), - new OA\Property(property: 'creation_date', type: 'string', format: 'date-time', example: '2023-01-01T12:00:00Z'), + new OA\Property( + property: 'creation_date', + type: 'string', + format: 'date-time', + example: '2023-01-01T12:00:00Z', + ), new OA\Property(property: 'confirmed', type: 'boolean', example: true), new OA\Property(property: 'blacklisted', type: 'boolean', example: false), new OA\Property(property: 'bounce_count', type: 'integer', example: 0), @@ -37,4 +47,6 @@ ], type: 'object' )] -class SwaggerSchemas {} +class SwaggerSchemas +{ +} diff --git a/src/Serializer/AdministratorTokenNormalizer.php b/src/Serializer/AdministratorTokenNormalizer.php index 9122531..74a45da 100644 --- a/src/Serializer/AdministratorTokenNormalizer.php +++ b/src/Serializer/AdministratorTokenNormalizer.php @@ -9,6 +9,9 @@ class AdministratorTokenNormalizer implements NormalizerInterface { + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ public function normalize($object, string $format = null, array $context = []): array { if (!$object instanceof AdministratorToken) { @@ -18,7 +21,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'key' => $object->getKey(), - 'expiry' => $object->getExpiry()->format('Y-m-d\TH:i:sP'), + 'expiry_date' => $object->getExpiry()->format('Y-m-d\TH:i:sP'), ]; } diff --git a/src/Validator/RequestValidator.php b/src/Validator/RequestValidator.php index ff7b62f..39e46c5 100644 --- a/src/Validator/RequestValidator.php +++ b/src/Validator/RequestValidator.php @@ -6,6 +6,7 @@ use PhpList\RestBundle\Entity\RequestInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -28,7 +29,7 @@ public function validate(Request $request, string $dtoClass): RequestInterface 'json' ); } catch (Throwable $e) { - throw new UnprocessableEntityHttpException('Invalid JSON: ' . $e->getMessage()); + throw new BadRequestHttpException('Invalid JSON: ' . $e->getMessage()); } return $this->validateDto($dto); diff --git a/tests/Integration/Controller/SessionControllerTest.php b/tests/Integration/Controller/SessionControllerTest.php index 72f1d58..6dcd4a1 100644 --- a/tests/Integration/Controller/SessionControllerTest.php +++ b/tests/Integration/Controller/SessionControllerTest.php @@ -46,11 +46,8 @@ public function testPostSessionsWithNoJsonReturnsError400() $this->jsonRequest('post', '/api/v2/sessions'); $this->assertHttpBadRequest(); - $this->assertJsonResponseContentEquals( - [ - 'message' => 'Empty JSON data', - ] - ); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('Invalid JSON:', $data['message']); } public function testPostSessionsWithInvalidJsonWithJsonContentTypeReturnsError400() @@ -58,21 +55,18 @@ public function testPostSessionsWithInvalidJsonWithJsonContentTypeReturnsError40 $this->jsonRequest('post', '/api/v2/sessions', [], [], [], 'Here be dragons, but no JSON.'); $this->assertHttpBadRequest(); - $this->assertJsonResponseContentEquals( - [ - 'message' => 'Could not decode request body.', - ] - ); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('Invalid JSON:', $data['message']); } - public function testPostSessionsWithValidEmptyJsonWithOtherTypeReturnsError400() + public function testPostSessionsWithValidEmptyJsonWithOtherTypeReturnsError422() { self::getClient()->request('post', '/api/v2/sessions', [], [], ['CONTENT_TYPE' => 'application/xml'], '[]'); - $this->assertHttpBadRequest(); + $this->assertHttpUnprocessableEntity(); $this->assertJsonResponseContentEquals( [ - 'message' => 'Incomplete credentials', + 'message' => "loginName: This value should not be blank.\npassword: This value should not be blank.", ] ); } @@ -84,7 +78,7 @@ public static function incompleteCredentialsDataProvider(): array { return [ 'neither login_name nor password' => ['{}'], - 'login_name, but no password' => ['{"login_name": "larry@example.com"}'], + 'login_name, but no password' => ['{"loginName": "larry@example.com"}'], 'password, but no login_name' => ['{"password": "t67809oibuzfq2qg3"}'], ]; } @@ -96,12 +90,9 @@ public function testPostSessionsWithValidIncompleteJsonReturnsError400(string $j { $this->jsonRequest('post', '/api/v2/sessions', [], [], [], $jsonData); - $this->assertHttpBadRequest(); - $this->assertJsonResponseContentEquals( - [ - 'message' => 'Incomplete credentials', - ] - ); + $this->assertHttpUnprocessableEntity(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('This value should not be blank', $data['message']); } public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() @@ -110,7 +101,7 @@ public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() $loginName = 'john.doe'; $password = 'a sandwich and a cup of coffee'; - $jsonData = ['login_name' => $loginName, 'password' => $password]; + $jsonData = ['loginName' => $loginName, 'password' => $password]; $this->jsonRequest('post', '/api/v2/sessions', [], [], [], json_encode($jsonData)); @@ -128,7 +119,7 @@ public function testPostSessionsActionWithValidCredentialsReturnsCreatedHttpStat $loginName = 'john.doe'; $password = 'Bazinga!'; - $jsonData = ['login_name' => $loginName, 'password' => $password]; + $jsonData = ['loginName' => $loginName, 'password' => $password]; $this->jsonRequest('post', '/api/v2/sessions', [], [], [], json_encode($jsonData)); @@ -139,7 +130,7 @@ public function testPostSessionsActionWithValidCredentialsCreatesToken() { $administratorId = 1; $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); - $jsonData = ['login_name' => 'john.doe', 'password' => 'Bazinga!']; + $jsonData = ['loginName' => 'john.doe', 'password' => 'Bazinga!']; $this->jsonRequest('post', '/api/v2/sessions', [], [], [], json_encode($jsonData)); diff --git a/tests/Integration/Controller/SubscriberControllerTest.php b/tests/Integration/Controller/SubscriberControllerTest.php index 3cd37cd..346c6e0 100644 --- a/tests/Integration/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Controller/SubscriberControllerTest.php @@ -95,6 +95,17 @@ public function testPostSubscribersWithValidSessionKeyAndExistingEmailAddressCre $this->assertHttpConflict(); } + /** + * @dataProvider invalidSubscriberDataProvider + * @param array[] $jsonData + */ + public function testPostSubscribersWithInvalidDataCreatesUnprocessableEntityStatus(array $jsonData) + { + $this->authenticatedJsonRequest('post', '/api/v2/subscribers', [], [], [], json_encode($jsonData)); + + $this->assertHttpUnprocessableEntity(); + } + /** * @return array[][] */ @@ -102,26 +113,34 @@ public static function invalidSubscriberDataProvider(): array { return [ 'no data' => [[]], - 'email is null' => [['email' => null]], 'email is an empty string' => [['email' => '']], 'email is invalid string' => [['email' => 'coffee and cigarettes']], - 'email as boolean' => [['email' => true]], - 'html_email as integer' => [['email' => 'kate@example.com', 'htmlEmail' => 1]], - 'html_email as string' => [['email' => 'kate@example.com', 'htmlEmail' => 'yes']], - 'request_confirmation as string' => [['email' => 'kate@example.com', 'requestConfirmation' => 'needed']], - 'disabled as string' => [['email' => 'kate@example.com', 'requestConfirmation' => 1]], ]; } /** - * @dataProvider invalidSubscriberDataProvider + * @dataProvider invalidDataProvider * @param array[] $jsonData */ - public function testPostSubscribersWithInvalidDataCreatesUnprocessableEntityStatus(array $jsonData) + public function testPostSubscribersWithInvalidJsonCreatesHttpBadRequestStatus(array $jsonData) { $this->authenticatedJsonRequest('post', '/api/v2/subscribers', [], [], [], json_encode($jsonData)); - $this->assertHttpUnprocessableEntity(); + $this->assertHttpBadRequest(); + } + + /** + * @return array[][] + */ + public static function invalidDataProvider(): array + { + return [ + 'email is null' => [['email' => null]], + 'email as boolean' => [['email' => true]], + 'html_email as integer' => [['email' => 'kate@example.com', 'htmlEmail' => 1]], + 'html_email as string' => [['email' => 'kate@example.com', 'htmlEmail' => 'yes']], + 'request_confirmation as string' => [['email' => 'kate@example.com', 'requestConfirmation' => 'needed']], + ]; } public function testPostSubscribersWithValidSessionKeyAssignsProvidedSubscriberData() diff --git a/tests/Integration/Service/Manager/SubscriberManagerTest.php b/tests/Integration/Service/Manager/SubscriberManagerTest.php index a2612a9..714381c 100644 --- a/tests/Integration/Service/Manager/SubscriberManagerTest.php +++ b/tests/Integration/Service/Manager/SubscriberManagerTest.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Tests\Integration\Service\Manager; +use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use PhpList\RestBundle\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; @@ -15,6 +16,7 @@ class SubscriberManagerTest extends TestCase public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity(): void { $repoMock = $this->createMock(SubscriberRepository::class); + $emMock = $this->createMock(EntityManagerInterface::class); $repoMock ->expects($this->once()) ->method('save') @@ -26,7 +28,7 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity( && $sub->isDisabled() === false; })); - $manager = new SubscriberManager($repoMock); + $manager = new SubscriberManager($repoMock, $emMock); $dto = new CreateSubscriberRequest(); $dto->email = 'foo@bar.com'; diff --git a/tests/Integration/Validator/RequestValidatorTest.php b/tests/Integration/Validator/RequestValidatorTest.php index df7d42f..4b103e8 100644 --- a/tests/Integration/Validator/RequestValidatorTest.php +++ b/tests/Integration/Validator/RequestValidatorTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\ConstraintViolation; @@ -66,7 +67,7 @@ public function testValidateThrowsOnInvalidJson(): void ->method('deserialize') ->willThrowException(new RuntimeException('Syntax error')); - $this->expectException(UnprocessableEntityHttpException::class); + $this->expectException(BadRequestHttpException::class); $this->expectExceptionMessage('Invalid JSON: Syntax error'); $this->requestValidator->validate($request, DummyRequestDto::class); diff --git a/tests/Integration/Validator/UniqueEmailValidatorTest.php b/tests/Integration/Validator/UniqueEmailValidatorTest.php index 069ee8a..c1c9942 100644 --- a/tests/Integration/Validator/UniqueEmailValidatorTest.php +++ b/tests/Integration/Validator/UniqueEmailValidatorTest.php @@ -4,25 +4,30 @@ namespace PhpList\RestBundle\Tests\Integration\Validator; +use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\RestBundle\Validator\UniqueEmail; use PhpList\RestBundle\Validator\UniqueEmailValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedValueException; class UniqueEmailValidatorTest extends TestCase { - private SubscriberRepository|MockObject $repo; + private SubscriberRepository|MockObject $repository; private UniqueEmailValidator $validator; + private ExecutionContextInterface|MockObject $context; protected function setUp(): void { - $this->repo = $this->createMock(SubscriberRepository::class); - $this->validator = new UniqueEmailValidator($this->repo); + $this->repository = $this->createMock(SubscriberRepository::class); + $this->validator = new UniqueEmailValidator($this->repository); + $this->context = $this->createMock(ExecutionContextInterface::class); + $this->validator->initialize($this->context); } public function testThrowsUnexpectedTypeExceptionWhenConstraintIsWrong(): void @@ -33,7 +38,7 @@ public function testThrowsUnexpectedTypeExceptionWhenConstraintIsWrong(): void public function testSkipsValidationForNullOrEmpty(): void { - $this->repo->expects(self::never())->method('findOneBy'); + $this->repository->expects(self::never())->method('findOneBy'); $this->validator->validate(null, new UniqueEmail()); $this->validator->validate('', new UniqueEmail()); @@ -47,15 +52,27 @@ public function testThrowsUnexpectedValueExceptionForNonString(): void $this->validator->validate(123, new UniqueEmail()); } - public function testThrowsConflictHttpExceptionWhenEmailAlreadyExists(): void + public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDifferentId(): void { $email = 'foo@bar.com'; - $this->repo + $existingUser = $this->createConfiguredMock(Subscriber::class, [ + 'getId' => 99 + ]); + + $this->repository ->expects(self::once()) ->method('findOneBy') ->with(['email' => $email]) - ->willReturn((object)['email' => $email]); + ->willReturn($existingUser); + + $dto = new class { + public int $subscriberId = 100; + }; + + $this->context + ->method('getObject') + ->willReturn($dto); $this->expectException(ConflictHttpException::class); $this->expectExceptionMessage('Email already exists.'); @@ -63,13 +80,47 @@ public function testThrowsConflictHttpExceptionWhenEmailAlreadyExists(): void $this->validator->validate($email, new UniqueEmail()); } + public function testAllowsSameEmailForSameSubscriberId(): void + { + $email = 'foo@bar.com'; + + $existingUser = $this->createConfiguredMock(Subscriber::class, [ + 'getId' => 100 + ]); + + $this->repository + ->expects(self::once()) + ->method('findOneBy') + ->willReturn($existingUser); + + $dto = new class { + public int $subscriberId = 100; + }; + + $this->context + ->method('getObject') + ->willReturn($dto); + + $this->validator->validate($email, new UniqueEmail()); + + $this->addToAssertionCount(1); + } + public function testAllowsUniqueEmailWhenNoExistingSubscriber(): void { - $this->repo + $this->repository ->expects(self::once()) ->method('findOneBy') ->willReturn(null); + $dto = new class { + public int $subscriberId = 200; + }; + + $this->context + ->method('getObject') + ->willReturn($dto); + $this->validator->validate('new@example.com', new UniqueEmail()); $this->addToAssertionCount(1); diff --git a/tests/System/Controller/SessionControllerTest.php b/tests/System/Controller/SessionControllerTest.php index 651d221..29ae7e3 100644 --- a/tests/System/Controller/SessionControllerTest.php +++ b/tests/System/Controller/SessionControllerTest.php @@ -21,7 +21,7 @@ public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() { $loginName = 'john.doe'; $password = 'a sandwich and a cup of coffee'; - $jsonData = ['login_name' => $loginName, 'password' => $password]; + $jsonData = ['loginName' => $loginName, 'password' => $password]; self::getClient()->request( 'POST', From 8a5f4cd26c8547b2f0309cda29f5cd88ff695593 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 8 Apr 2025 21:43:25 +0400 Subject: [PATCH 14/62] ISSUE-345: refactor swagger --- src/Controller/ListController.php | 66 +++++-------------------- src/Controller/SessionController.php | 10 +--- src/Controller/SubscriberController.php | 32 ++---------- src/OpenApi/SwaggerSchemas.php | 11 +++++ 4 files changed, 27 insertions(+), 92 deletions(-) diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index acea76a..5f30bd4 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -26,6 +26,7 @@ * @author Xheni Myrtaj * @author Tatevik Grigoryan */ +#[Route('/lists')] class ListController extends AbstractController { use AuthenticationTrait; @@ -46,7 +47,7 @@ public function __construct( $this->serializer = $serializer; } - #[Route('/lists', name: 'get_lists', methods: ['GET'])] + #[Route('/', name: 'get_lists', methods: ['GET'])] #[OA\Get( path: '/lists', description: 'Returns a JSON list of all subscriber lists.', @@ -96,16 +97,7 @@ public function __construct( new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ) ] )] @@ -120,7 +112,7 @@ public function getLists(Request $request): JsonResponse return new JsonResponse($json, Response::HTTP_OK, [], true); } - #[Route('/lists/{listId}', name: 'get_list', methods: ['GET'])] + #[Route('/{listId}', name: 'get_list', methods: ['GET'])] #[OA\Get( path: '/lists/{listId}', description: 'Returns a single subscriber list with specified ID.', @@ -163,16 +155,7 @@ public function getLists(Request $request): JsonResponse new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( response: 404, @@ -202,7 +185,7 @@ public function getList( return new JsonResponse($json, Response::HTTP_OK, [], true); } - #[Route('/lists/{listId}', name: 'delete_list', methods: ['DELETE'])] + #[Route('/{listId}', name: 'delete_list', methods: ['DELETE'])] #[OA\Delete( path: '/lists/{listId}', description: 'Deletes a single subscriber list.', @@ -232,16 +215,7 @@ public function getList( new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided.' - ) - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( response: 404, @@ -270,7 +244,7 @@ public function deleteList( return new JsonResponse(null, Response::HTTP_NO_CONTENT, [], false); } - #[Route('/lists/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] + #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] #[OA\Get( path: '/lists/{listId}/subscribers', description: 'Returns a JSON list of all subscribers for a subscriber list.', @@ -304,16 +278,7 @@ public function deleteList( new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ) ] )] @@ -332,7 +297,7 @@ public function getListMembers( return new JsonResponse($json, Response::HTTP_OK, [], true); } - #[Route('/lists/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] + #[Route('/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] #[OA\Get( path: '/lists/{listId}/count', description: 'Returns a count of all subscribers in a given list.', @@ -362,16 +327,7 @@ public function getListMembers( new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ) ] )] diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index 23f4e10..cc9d022 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -136,15 +136,7 @@ public function createSession( new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( response: 404, diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index dd0cca1..598cea0 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -76,15 +76,7 @@ public function __construct(Authentication $authentication, SubscriberManager $s new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( response: 409, @@ -176,15 +168,7 @@ public function createSubscriber( new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( response: 404, @@ -192,7 +176,7 @@ public function createSubscriber( ) ] )] - public function update( + public function updateSubscriber( Request $request, #[MapEntity(mapping: ['subscriberId' => 'id'])] Subscriber $subscriber, SerializerInterface $serializer, @@ -246,15 +230,7 @@ public function update( new OA\Response( response: 403, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'No valid session key was provided as basic auth password.' - ) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( response: 404, diff --git a/src/OpenApi/SwaggerSchemas.php b/src/OpenApi/SwaggerSchemas.php index ed93d67..c894e01 100644 --- a/src/OpenApi/SwaggerSchemas.php +++ b/src/OpenApi/SwaggerSchemas.php @@ -47,6 +47,17 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'UnauthorizedResponse', + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'No valid session key was provided as basic auth password.' + ) + ], + type: 'object' +)] class SwaggerSchemas { } From dc3ff0b75dbaa85abc3142216f71d0d5a5ead26e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 8 Apr 2025 21:45:54 +0400 Subject: [PATCH 15/62] ISSUE-345: delete subscriber endpoint --- src/Controller/SubscriberController.php | 59 +++++++++++++++++++++++ src/Service/Manager/SubscriberManager.php | 5 ++ 2 files changed, 64 insertions(+) diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 598cea0..96566bd 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -251,4 +251,63 @@ public function getSubscriber(Request $request, int $subscriberId, SubscriberNor false ); } + + + #[Route('/{subscriberId}', name: 'delete_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])] + #[OA\Delete( + path: '/subscribers/{subscriberId}', + description: 'Delete subscriber by id.', + summary: 'Delete subscriber', + requestBody: new OA\RequestBody( + description: 'Pass session credentials', + required: true, + ), + tags: ['subscribers'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'subscriberId', + description: 'Subscriber ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 204, + description: 'Success', + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Not Found', + ) + ] + )] + public function deleteSubscriber( + Request $request, + #[MapEntity(mapping: ['subscriberId' => 'id'])] Subscriber $subscriber, + ): JsonResponse { + $this->requireAuthentication($request); + + $this->subscriberManager->deleteSubscriber($subscriber); + + return new JsonResponse( + null, + Response::HTTP_NO_CONTENT, + [], + false + ); + } } diff --git a/src/Service/Manager/SubscriberManager.php b/src/Service/Manager/SubscriberManager.php index 1b05ae4..b063ba5 100644 --- a/src/Service/Manager/SubscriberManager.php +++ b/src/Service/Manager/SubscriberManager.php @@ -64,4 +64,9 @@ public function updateSubscriber(UpdateSubscriberRequest $subscriberRequest): Su return $subscriber; } + + public function deleteSubscriber(Subscriber $subscriber): void + { + $this->subscriberRepository->remove($subscriber); + } } From 624c11a5d991876247b52adfabcf3d654ae29047 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 8 Apr 2025 21:51:52 +0400 Subject: [PATCH 16/62] ISSUE-345: 422 in schema --- src/Controller/ListController.php | 2 +- src/Controller/SubscriberController.php | 17 +++++++---------- src/Controller/Traits/AuthenticationTrait.php | 13 ++++--------- src/OpenApi/SwaggerSchemas.php | 11 +++++++++++ 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 5f30bd4..233dcb9 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -47,7 +47,7 @@ public function __construct( $this->serializer = $serializer; } - #[Route('/', name: 'get_lists', methods: ['GET'])] + #[Route('', name: 'get_lists', methods: ['GET'])] #[OA\Get( path: '/lists', description: 'Returns a JSON list of all subscriber lists.', diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 96566bd..7b330f5 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -90,16 +90,8 @@ public function __construct(Authentication $authentication, SubscriberManager $s new OA\Response( response: 422, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'Some fields invalid: email, confirmed, html_email' - ) - ] - ) - ) + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), ] )] public function createSubscriber( @@ -170,6 +162,11 @@ public function createSubscriber( description: 'Failure', content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), new OA\Response( response: 404, description: 'Not Found', diff --git a/src/Controller/Traits/AuthenticationTrait.php b/src/Controller/Traits/AuthenticationTrait.php index 63961d2..6df8261 100644 --- a/src/Controller/Traits/AuthenticationTrait.php +++ b/src/Controller/Traits/AuthenticationTrait.php @@ -1,4 +1,5 @@ + * @author Tatevik Grigoryan */ trait AuthenticationTrait { - /** - * @var Authentication - */ - private $authentication = null; + private ?Authentication $authentication = null; /** * Checks for valid authentication in the given request and throws an exception if there is none. * - * @param Request $request - * - * @return Administrator the authenticated administrator - * * @throws AccessDeniedHttpException */ private function requireAuthentication(Request $request): Administrator diff --git a/src/OpenApi/SwaggerSchemas.php b/src/OpenApi/SwaggerSchemas.php index c894e01..d0dbab2 100644 --- a/src/OpenApi/SwaggerSchemas.php +++ b/src/OpenApi/SwaggerSchemas.php @@ -58,6 +58,17 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'ValidationErrorResponse', + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'Some fields invalid: email, confirmed, html_email' + ) + ], + type: 'object' +)] class SwaggerSchemas { } From 0b619ec3f6cf74d6fa8d5f5d667f2a02bc2e22b3 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Apr 2025 20:11:41 +0400 Subject: [PATCH 17/62] ISSUE-345: create subscriber list endpoint --- config/services.yml | 4 - config/services/managers.yml | 4 + config/services/normalizers.yml | 4 + src/Controller/ListController.php | 81 ++++++++++++++++++- src/Entity/CreateSubscriberListRequest.php | 27 +++++++ src/Serializer/SubscriberListNormalizer.php | 40 +++++++++ src/Service/Manager/SubscriberListManager.php | 37 +++++++++ .../Fixtures/SubscriberListFixture.php | 2 +- .../Fixtures/SubscriptionFixture.php | 2 +- .../Controller/ListControllerTest.php | 2 +- 10 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 src/Entity/CreateSubscriberListRequest.php create mode 100644 src/Serializer/SubscriberListNormalizer.php create mode 100644 src/Service/Manager/SubscriberListManager.php diff --git a/config/services.yml b/config/services.yml index a126a15..24cec05 100644 --- a/config/services.yml +++ b/config/services.yml @@ -21,7 +21,3 @@ services: PhpList\Core\Security\Authentication: autowire: true autoconfigure: true - - PhpList\Core\Domain\Repository\Messaging\SubscriberListRepository: - autowire: true - autoconfigure: true diff --git a/config/services/managers.yml b/config/services/managers.yml index d79363d..f690b77 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -11,3 +11,7 @@ services: PhpList\RestBundle\Service\Manager\SessionManager: autowire: true autoconfigure: true + + PhpList\RestBundle\Service\Manager\SubscriberListManager: + autowire: true + autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 25f127f..9c9c9f7 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -18,3 +18,7 @@ services: PhpList\RestBundle\Serializer\AdministratorTokenNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Serializer\SubscriberListNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 233dcb9..41d9698 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -4,11 +4,15 @@ namespace PhpList\RestBundle\Controller; -use PhpList\Core\Domain\Model\Messaging\SubscriberList; +use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; +use PhpList\RestBundle\Entity\CreateSubscriberListRequest; +use PhpList\RestBundle\Serializer\SubscriberListNormalizer; +use PhpList\RestBundle\Service\Manager\SubscriberListManager; +use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Repository\Messaging\SubscriberListRepository; +use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; @@ -34,17 +38,21 @@ class ListController extends AbstractController private SubscriberListRepository $subscriberListRepository; private SubscriberRepository $subscriberRepository; private SerializerInterface $serializer; + private SubscriberListManager $subscriberListManager; + private RequestValidator $validator; public function __construct( Authentication $authentication, SubscriberListRepository $repository, SubscriberRepository $subscriberRepository, - SerializerInterface $serializer + SerializerInterface $serializer, + RequestValidator $validator ) { $this->authentication = $authentication; $this->subscriberListRepository = $repository; $this->subscriberRepository = $subscriberRepository; $this->serializer = $serializer; + $this->validator = $validator; } #[Route('', name: 'get_lists', methods: ['GET'])] @@ -340,4 +348,71 @@ public function getSubscribersCount( return new JsonResponse($json, Response::HTTP_OK, [], true); } + + #[Route('', name: 'create_list', methods: ['POST'])] + #[OA\Post( + path: '/lists', + description: 'Returns created list.', + summary: 'Create a subscriber list.', + requestBody: new OA\RequestBody( + description: 'Pass parameters to create a new subscriber list.', + required: true, + content: new OA\JsonContent( + required: ['name'], + properties: [ + new OA\Property(property: 'name', type: 'string', format: 'string', example: 'News'), + new OA\Property(property: 'description', type: 'string', example: 'News (and some fun stuff)'), + new OA\Property(property: 'list_position', type: 'number', example: 12), + new OA\Property(property: 'public', type: 'boolean', example: true), + new OA\Property(property: 'owner', type: 'number', example: 12), + ] + ) + ), + tags: ['lists'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ) + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent( + type: 'object', + example: [ + 'name' => 'News', + 'description' => 'News (and some fun stuff)', + 'creation_date' => '2016-06-22T15:01:17+00:00', + 'list_position' => 12, + 'subject_prefix' => 'phpList', + 'public' => true, + 'category' => 'news', + 'id' => 1 + ] + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function createList(Request $request, SubscriberListNormalizer $normalizer): JsonResponse + { + $this->requireAuthentication($request); + + /** @var CreateSubscriberListRequest $subscriberListRequest */ + $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); + $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest); + + return new JsonResponse($normalizer->normalize($data), Response::HTTP_CREATED, [], true); + } } diff --git a/src/Entity/CreateSubscriberListRequest.php b/src/Entity/CreateSubscriberListRequest.php new file mode 100644 index 0000000..50c46a5 --- /dev/null +++ b/src/Entity/CreateSubscriberListRequest.php @@ -0,0 +1,27 @@ + $object->getId(), + 'name' => $object->getName(), + 'creation_date' => $object->getCreationDate()->format('Y-m-d\TH:i:sP'), + 'description' => $object->getDescription(), + 'list_position' => $object->getListPosition(), + 'subject_prefix' => $object->getSubjectPrefix(), + 'public' => $object->isPublic(), + 'category' => $object->getCategory(), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof SubscriberList; + } +} diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php new file mode 100644 index 0000000..c5a674f --- /dev/null +++ b/src/Service/Manager/SubscriberListManager.php @@ -0,0 +1,37 @@ +subscriberListRepository = $subscriberListRepository; + $this->entityManager = $entityManager; + } + + public function createSubscriberList(CreateSubscriberListRequest $subscriberListRequest): SubscriberList + { + $subscriberList = (new SubscriberList()) + ->setName($subscriberListRequest->name) + ->setDescription($subscriberListRequest->description) + ->setListPosition($subscriberListRequest->listPosition) + ->setPublic($subscriberListRequest->public); + + $this->subscriberListRepository->save($subscriberList); + + return $subscriberList; + } +} diff --git a/tests/Integration/Controller/Fixtures/SubscriberListFixture.php b/tests/Integration/Controller/Fixtures/SubscriberListFixture.php index 5ef4955..93287ff 100644 --- a/tests/Integration/Controller/Fixtures/SubscriberListFixture.php +++ b/tests/Integration/Controller/Fixtures/SubscriberListFixture.php @@ -8,7 +8,7 @@ use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\Core\Domain\Model\Messaging\SubscriberList; +use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Controller/Fixtures/SubscriptionFixture.php b/tests/Integration/Controller/Fixtures/SubscriptionFixture.php index 88be840..66b4028 100644 --- a/tests/Integration/Controller/Fixtures/SubscriptionFixture.php +++ b/tests/Integration/Controller/Fixtures/SubscriptionFixture.php @@ -7,7 +7,7 @@ use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Messaging\SubscriberList; +use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Model\Subscription\Subscription; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index 5574f9c..e7bdc4e 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Tests\Integration\Controller; -use PhpList\Core\Domain\Repository\Messaging\SubscriberListRepository; +use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\RestBundle\Controller\ListController; use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorFixture; use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorTokenFixture; From eb91c4ac62b6af1665191e8c6a68b960274df592 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Apr 2025 20:48:58 +0400 Subject: [PATCH 18/62] ISSUE-345: new version --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 28c4171..a0b6d9c 100644 --- a/composer.json +++ b/composer.json @@ -29,9 +29,15 @@ "forum": "https://discuss.phplist.org/", "source": "https://github.com/phpList/rest-api" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/phpList/core" + } + ], "require": { "php": "^8.1", - "phplist/core": "v5.0.0-alpha4", + "phplist/core": "dev-ISSUE-345", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", From 73368d1c76e589d386a99c2a2ede5e44efafafd3 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Apr 2025 20:56:49 +0400 Subject: [PATCH 19/62] ISSUE-345: test fix --- .../Controller/ListControllerTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index e7bdc4e..08c236f 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -63,34 +63,34 @@ public function testGetListsWithCurrentSessionKeyReturnsListData() $this->assertJsonResponseContentEquals( [ [ + 'id' => 1, 'name' => 'News', - 'description' => 'News (and some fun stuff)', 'creation_date' => '2016-06-22T15:01:17+00:00', + 'description' => 'News (and some fun stuff)', 'list_position' => 12, 'subject_prefix' => 'phpList', 'public' => true, 'category' => 'news', - 'id' => 1, ], [ + 'id' => 2, 'name' => 'More news', - 'description' => '', 'creation_date' => '2016-06-22T15:01:17+00:00', + 'description' => '', 'list_position' => 12, 'subject_prefix' => '', 'public' => true, 'category' => '', - 'id' => 2, ], [ + 'id' => 3, 'name' => 'Tech news', - 'description' => '', 'creation_date' => '2019-02-11T15:01:15+00:00', + 'description' => '', 'list_position' => 12, 'subject_prefix' => '', 'public' => true, 'category' => '', - 'id' => 3, ], ] ); @@ -129,14 +129,14 @@ public function testGetListWithCurrentSessionKeyReturnsListData() $this->assertJsonResponseContentEquals( [ + 'id' => 1, 'name' => 'News', - 'description' => 'News (and some fun stuff)', 'creation_date' => '2016-06-22T15:01:17+00:00', + 'description' => 'News (and some fun stuff)', 'list_position' => 12, 'subject_prefix' => 'phpList', 'public' => true, 'category' => 'news', - 'id' => 1, ] ); } From 7910d2e640d5b87363e4960bc78dc09541ebe7cb Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Apr 2025 21:00:57 +0400 Subject: [PATCH 20/62] ISSUE-345: pipeline fix --- src/Controller/ListController.php | 4 +++- src/Service/Manager/SubscriberListManager.php | 10 ++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 41d9698..9f9ae20 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -46,13 +46,15 @@ public function __construct( SubscriberListRepository $repository, SubscriberRepository $subscriberRepository, SerializerInterface $serializer, - RequestValidator $validator + RequestValidator $validator, + SubscriberListManager $subscriberListManager ) { $this->authentication = $authentication; $this->subscriberListRepository = $repository; $this->subscriberRepository = $subscriberRepository; $this->serializer = $serializer; $this->validator = $validator; + $this->subscriberListManager = $subscriberListManager; } #[Route('', name: 'get_lists', methods: ['GET'])] diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php index c5a674f..e70a7cd 100644 --- a/src/Service/Manager/SubscriberListManager.php +++ b/src/Service/Manager/SubscriberListManager.php @@ -4,7 +4,6 @@ namespace PhpList\RestBundle\Service\Manager; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\RestBundle\Entity\CreateSubscriberListRequest; @@ -12,14 +11,9 @@ class SubscriberListManager { private SubscriberListRepository $subscriberListRepository; - private EntityManagerInterface $entityManager; - - public function __construct( - SubscriberListRepository $subscriberListRepository, - EntityManagerInterface $entityManager - ) { + public function __construct(SubscriberListRepository $subscriberListRepository) + { $this->subscriberListRepository = $subscriberListRepository; - $this->entityManager = $entityManager; } public function createSubscriberList(CreateSubscriberListRequest $subscriberListRequest): SubscriberList From 799e514ab87664f6e71aab2484352921d748e69e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Apr 2025 21:04:36 +0400 Subject: [PATCH 21/62] ISSUE-345: doc fix --- composer.json | 6 ------ src/Controller/SubscriberController.php | 8 +------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/composer.json b/composer.json index a0b6d9c..367bdf6 100644 --- a/composer.json +++ b/composer.json @@ -29,12 +29,6 @@ "forum": "https://discuss.phplist.org/", "source": "https://github.com/phpList/rest-api" }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/phpList/core" - } - ], "require": { "php": "^8.1", "phplist/core": "dev-ISSUE-345", diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 7b330f5..bf24400 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -114,7 +114,7 @@ public function createSubscriber( } #[Route('/{subscriberId}', name: 'update_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['PUT'])] - #[OA\Get( + #[OA\Put( path: '/subscribers/{subscriberId}', description: 'Update subscriber data by id.', summary: 'Update subscriber', @@ -125,7 +125,6 @@ public function createSubscriber( required: ['email'], properties: [ new OA\Property(property: 'email', type: 'string', format: 'string', example: 'admin@example.com'), - new OA\Property(property: 'html_email', type: 'boolean', example: false), new OA\Property(property: 'confirmed', type: 'boolean', example: false), new OA\Property(property: 'blacklisted', type: 'boolean', example: false), new OA\Property(property: 'html_email', type: 'boolean', example: false), @@ -249,16 +248,11 @@ public function getSubscriber(Request $request, int $subscriberId, SubscriberNor ); } - #[Route('/{subscriberId}', name: 'delete_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( path: '/subscribers/{subscriberId}', description: 'Delete subscriber by id.', summary: 'Delete subscriber', - requestBody: new OA\RequestBody( - description: 'Pass session credentials', - required: true, - ), tags: ['subscribers'], parameters: [ new OA\Parameter( From c2a0abfdadb3f68e5168621b9cc4ab6b0ed4bd36 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 12 Apr 2025 19:47:22 +0400 Subject: [PATCH 22/62] ISSUE-345: subscriberListManager methods --- src/Controller/ListController.php | 69 ++++++++++--------- src/Service/Manager/SubscriberListManager.php | 28 +++++++- .../Controller/ListControllerTest.php | 8 +-- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 9f9ae20..8cda7cf 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -5,22 +5,19 @@ namespace PhpList\RestBundle\Controller; use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\RestBundle\Entity\CreateSubscriberListRequest; use PhpList\RestBundle\Serializer\SubscriberListNormalizer; +use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Service\Manager\SubscriberListManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -use Symfony\Component\Serializer\SerializerInterface; use OpenApi\Attributes as OA; /** @@ -35,24 +32,18 @@ class ListController extends AbstractController { use AuthenticationTrait; - private SubscriberListRepository $subscriberListRepository; - private SubscriberRepository $subscriberRepository; - private SerializerInterface $serializer; + private SubscriberListNormalizer $normalizer; private SubscriberListManager $subscriberListManager; private RequestValidator $validator; public function __construct( Authentication $authentication, - SubscriberListRepository $repository, - SubscriberRepository $subscriberRepository, - SerializerInterface $serializer, + SubscriberListNormalizer $normalizer, RequestValidator $validator, SubscriberListManager $subscriberListManager ) { $this->authentication = $authentication; - $this->subscriberListRepository = $repository; - $this->subscriberRepository = $subscriberRepository; - $this->serializer = $serializer; + $this->normalizer = $normalizer; $this->validator = $validator; $this->subscriberListManager = $subscriberListManager; } @@ -114,12 +105,13 @@ public function __construct( public function getLists(Request $request): JsonResponse { $this->requireAuthentication($request); - $data = $this->subscriberListRepository->findAll(); - $json = $this->serializer->serialize($data, 'json', [ - AbstractNormalizer::GROUPS => 'SubscriberList', - ]); + $data = $this->subscriberListManager->getAll(); - return new JsonResponse($json, Response::HTTP_OK, [], true); + $normalized = array_map(function ($item) { + return $this->normalizer->normalize($item); + }, $data); + + return new JsonResponse($normalized, Response::HTTP_OK); } #[Route('/{listId}', name: 'get_list', methods: ['GET'])] @@ -188,11 +180,8 @@ public function getList( #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list ): JsonResponse { $this->requireAuthentication($request); - $json = $this->serializer->serialize($list, 'json', [ - AbstractNormalizer::GROUPS => 'SubscriberList', - ]); - return new JsonResponse($json, Response::HTTP_OK, [], true); + return new JsonResponse($this->normalizer->normalize($list), Response::HTTP_OK); } #[Route('/{listId}', name: 'delete_list', methods: ['DELETE'])] @@ -249,9 +238,9 @@ public function deleteList( ): JsonResponse { $this->requireAuthentication($request); - $this->subscriberListRepository->remove($list); + $this->subscriberListManager->delete($list); - return new JsonResponse(null, Response::HTTP_NO_CONTENT, [], false); + return new JsonResponse(null, Response::HTTP_NO_CONTENT); } #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] @@ -294,17 +283,17 @@ public function deleteList( )] public function getListMembers( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list + #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, + SubscriberNormalizer $normalizer ): JsonResponse { $this->requireAuthentication($request); - $subscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); - - $json = $this->serializer->serialize($subscribers, 'json', [ - AbstractNormalizer::GROUPS => 'SubscriberListMembers', - ]); + $subscribers = $this->subscriberListManager->getSubscriberListMembers($list); + $normalized = array_map(function ($item) use ($normalizer) { + return $normalizer->normalize($item); + }, $subscribers); - return new JsonResponse($json, Response::HTTP_OK, [], true); + return new JsonResponse($normalized, Response::HTTP_OK); } #[Route('/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] @@ -332,7 +321,17 @@ public function getListMembers( responses: [ new OA\Response( response: 200, - description: 'Success' + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'subscribers_count', + type: 'integer', + example: 42 + ) + ], + type: 'object' + ) ), new OA\Response( response: 403, @@ -346,9 +345,11 @@ public function getSubscribersCount( #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list ): JsonResponse { $this->requireAuthentication($request); - $json = $this->serializer->serialize(count($list->getSubscribers()), 'json'); - return new JsonResponse($json, Response::HTTP_OK, [], true); + return new JsonResponse( + ['subscribers_count' => count($list->getSubscribers())], + Response::HTTP_OK, + ); } #[Route('', name: 'create_list', methods: ['POST'])] diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php index e70a7cd..87f5f69 100644 --- a/src/Service/Manager/SubscriberListManager.php +++ b/src/Service/Manager/SubscriberListManager.php @@ -4,16 +4,23 @@ namespace PhpList\RestBundle\Service\Manager; +use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; +use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\RestBundle\Entity\CreateSubscriberListRequest; class SubscriberListManager { private SubscriberListRepository $subscriberListRepository; - public function __construct(SubscriberListRepository $subscriberListRepository) - { + private SubscriberRepository $subscriberRepository; + + public function __construct( + SubscriberListRepository $subscriberListRepository, + SubscriberRepository $subscriberRepository + ) { $this->subscriberListRepository = $subscriberListRepository; + $this->subscriberRepository = $subscriberRepository; } public function createSubscriberList(CreateSubscriberListRequest $subscriberListRequest): SubscriberList @@ -28,4 +35,21 @@ public function createSubscriberList(CreateSubscriberListRequest $subscriberList return $subscriberList; } + + /** @return SubscriberList[] */ + public function getAll(): array + { + return $this->subscriberListRepository->findAll(); + } + + public function delete(SubscriberList $subscriberList): void + { + $this->subscriberListRepository->remove($subscriberList); + } + + /** @return Subscriber[] */ + public function getSubscriberListMembers(SubscriberList $list): array + { + return $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); + } } diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index 08c236f..bb1313b 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -329,9 +329,9 @@ public function testGetSubscribersCountForEmptyListWithValidSession() $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); $this->authenticatedJsonRequest('get', '/api/v2/lists/3/subscribers/count'); - $responseContent = $this->getResponseContentAsInt(); + $responseData = $this->getDecodedJsonResponseContent(); - self::assertSame(0, $responseContent); + self::assertSame(0, $responseData['subscribers_count']); } public function testGetSubscribersCountForListWithValidSession() @@ -339,8 +339,8 @@ public function testGetSubscribersCountForListWithValidSession() $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); $this->authenticatedJsonRequest('get', '/api/v2/lists/2/subscribers/count'); - $responseContent = $this->getResponseContentAsInt(); + $responseData = $this->getDecodedJsonResponseContent(); - self::assertSame(2, $responseContent); + self::assertSame(2, $responseData['subscribers_count']); } } From c501c0e021c40146550c412e8596c74f6888c347 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 12 Apr 2025 21:47:44 +0400 Subject: [PATCH 23/62] ISSUE-345: Request folder --- src/Controller/ListController.php | 8 ++++---- src/Controller/SessionController.php | 10 +++++----- src/Controller/SubscriberController.php | 4 ++-- src/Entity/{ => Request}/CreateSessionRequest.php | 2 +- .../{ => Request}/CreateSubscriberListRequest.php | 2 +- src/Entity/{ => Request}/CreateSubscriberRequest.php | 2 +- src/Entity/{ => Request}/RequestInterface.php | 2 +- src/Entity/{ => Request}/UpdateSubscriberRequest.php | 2 +- src/Service/Manager/SessionManager.php | 2 +- src/Service/Manager/SubscriberListManager.php | 2 +- src/Service/Manager/SubscriberManager.php | 4 ++-- src/Validator/RequestValidator.php | 2 +- tests/Helpers/DummyRequestDto.php | 2 +- .../Service/Manager/SubscriberManagerTest.php | 8 ++++---- tests/Integration/Validator/RequestValidatorTest.php | 2 +- 15 files changed, 27 insertions(+), 27 deletions(-) rename src/Entity/{ => Request}/CreateSessionRequest.php (87%) rename src/Entity/{ => Request}/CreateSubscriberListRequest.php (90%) rename src/Entity/{ => Request}/CreateSubscriberRequest.php (90%) rename src/Entity/{ => Request}/RequestInterface.php (59%) rename src/Entity/{ => Request}/UpdateSubscriberRequest.php (93%) diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 8cda7cf..7b749db 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -4,21 +4,21 @@ namespace PhpList\RestBundle\Controller; +use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Subscription\SubscriberList; -use PhpList\RestBundle\Entity\CreateSubscriberListRequest; +use PhpList\Core\Security\Authentication; +use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; +use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; use PhpList\RestBundle\Serializer\SubscriberListNormalizer; use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Service\Manager\SubscriberListManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use OpenApi\Attributes as OA; /** * This controller provides REST API access to subscriber lists. diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index cc9d022..9f6ab53 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -4,21 +4,21 @@ namespace PhpList\RestBundle\Controller; -use PhpList\RestBundle\Entity\CreateSessionRequest; +use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Model\Identity\AdministratorToken; +use PhpList\Core\Security\Authentication; +use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; +use PhpList\RestBundle\Entity\Request\CreateSessionRequest; use PhpList\RestBundle\Serializer\AdministratorTokenNormalizer; use PhpList\RestBundle\Service\Manager\SessionManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Model\Identity\AdministratorToken; -use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Attribute\Route; -use OpenApi\Attributes as OA; /** * This controller provides methods to create and destroy REST API sessions. diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index bf24400..60fbb97 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -8,8 +8,8 @@ use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; -use PhpList\RestBundle\Entity\CreateSubscriberRequest; -use PhpList\RestBundle\Entity\UpdateSubscriberRequest; +use PhpList\RestBundle\Entity\Request\CreateSubscriberRequest; +use PhpList\RestBundle\Entity\Request\UpdateSubscriberRequest; use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Service\Manager\SubscriberManager; use PhpList\RestBundle\Validator\RequestValidator; diff --git a/src/Entity/CreateSessionRequest.php b/src/Entity/Request/CreateSessionRequest.php similarity index 87% rename from src/Entity/CreateSessionRequest.php rename to src/Entity/Request/CreateSessionRequest.php index e97768b..9291358 100644 --- a/src/Entity/CreateSessionRequest.php +++ b/src/Entity/Request/CreateSessionRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Entity; +namespace PhpList\RestBundle\Entity\Request; use Symfony\Component\Validator\Constraints as Assert; diff --git a/src/Entity/CreateSubscriberListRequest.php b/src/Entity/Request/CreateSubscriberListRequest.php similarity index 90% rename from src/Entity/CreateSubscriberListRequest.php rename to src/Entity/Request/CreateSubscriberListRequest.php index 50c46a5..5fab655 100644 --- a/src/Entity/CreateSubscriberListRequest.php +++ b/src/Entity/Request/CreateSubscriberListRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Entity; +namespace PhpList\RestBundle\Entity\Request; use Symfony\Component\Validator\Constraints as Assert; diff --git a/src/Entity/CreateSubscriberRequest.php b/src/Entity/Request/CreateSubscriberRequest.php similarity index 90% rename from src/Entity/CreateSubscriberRequest.php rename to src/Entity/Request/CreateSubscriberRequest.php index 000a425..74bd0bf 100644 --- a/src/Entity/CreateSubscriberRequest.php +++ b/src/Entity/Request/CreateSubscriberRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Entity; +namespace PhpList\RestBundle\Entity\Request; use PhpList\RestBundle\Validator as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; diff --git a/src/Entity/RequestInterface.php b/src/Entity/Request/RequestInterface.php similarity index 59% rename from src/Entity/RequestInterface.php rename to src/Entity/Request/RequestInterface.php index 86b6a72..5c13ace 100644 --- a/src/Entity/RequestInterface.php +++ b/src/Entity/Request/RequestInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Entity; +namespace PhpList\RestBundle\Entity\Request; interface RequestInterface { diff --git a/src/Entity/UpdateSubscriberRequest.php b/src/Entity/Request/UpdateSubscriberRequest.php similarity index 93% rename from src/Entity/UpdateSubscriberRequest.php rename to src/Entity/Request/UpdateSubscriberRequest.php index a895966..6ee254e 100644 --- a/src/Entity/UpdateSubscriberRequest.php +++ b/src/Entity/Request/UpdateSubscriberRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Entity; +namespace PhpList\RestBundle\Entity\Request; use PhpList\RestBundle\Validator as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; diff --git a/src/Service/Manager/SessionManager.php b/src/Service/Manager/SessionManager.php index 05339f8..f0d02cc 100644 --- a/src/Service/Manager/SessionManager.php +++ b/src/Service/Manager/SessionManager.php @@ -7,7 +7,7 @@ use PhpList\Core\Domain\Model\Identity\AdministratorToken; use PhpList\Core\Domain\Repository\Identity\AdministratorRepository; use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; -use PhpList\RestBundle\Entity\CreateSessionRequest; +use PhpList\RestBundle\Entity\Request\CreateSessionRequest; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; class SessionManager diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php index 87f5f69..1c23df7 100644 --- a/src/Service/Manager/SubscriberListManager.php +++ b/src/Service/Manager/SubscriberListManager.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\RestBundle\Entity\CreateSubscriberListRequest; +use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; class SubscriberListManager { diff --git a/src/Service/Manager/SubscriberManager.php b/src/Service/Manager/SubscriberManager.php index b063ba5..174dcdc 100644 --- a/src/Service/Manager/SubscriberManager.php +++ b/src/Service/Manager/SubscriberManager.php @@ -7,8 +7,8 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\RestBundle\Entity\CreateSubscriberRequest; -use PhpList\RestBundle\Entity\UpdateSubscriberRequest; +use PhpList\RestBundle\Entity\Request\CreateSubscriberRequest; +use PhpList\RestBundle\Entity\Request\UpdateSubscriberRequest; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class SubscriberManager diff --git a/src/Validator/RequestValidator.php b/src/Validator/RequestValidator.php index 39e46c5..4868f28 100644 --- a/src/Validator/RequestValidator.php +++ b/src/Validator/RequestValidator.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Validator; -use PhpList\RestBundle\Entity\RequestInterface; +use PhpList\RestBundle\Entity\Request\RequestInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; diff --git a/tests/Helpers/DummyRequestDto.php b/tests/Helpers/DummyRequestDto.php index 11e45f7..e4ec8ff 100644 --- a/tests/Helpers/DummyRequestDto.php +++ b/tests/Helpers/DummyRequestDto.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Tests\Helpers; -use PhpList\RestBundle\Entity\RequestInterface; +use PhpList\RestBundle\Entity\Request\RequestInterface; class DummyRequestDto implements RequestInterface { diff --git a/tests/Integration/Service/Manager/SubscriberManagerTest.php b/tests/Integration/Service/Manager/SubscriberManagerTest.php index 714381c..e1058a5 100644 --- a/tests/Integration/Service/Manager/SubscriberManagerTest.php +++ b/tests/Integration/Service/Manager/SubscriberManagerTest.php @@ -5,11 +5,11 @@ namespace PhpList\RestBundle\Tests\Integration\Service\Manager; use Doctrine\ORM\EntityManagerInterface; -use PHPUnit\Framework\TestCase; -use PhpList\RestBundle\Service\Manager\SubscriberManager; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\RestBundle\Entity\CreateSubscriberRequest; use PhpList\Core\Domain\Model\Subscription\Subscriber; +use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; +use PhpList\RestBundle\Entity\Request\CreateSubscriberRequest; +use PhpList\RestBundle\Service\Manager\SubscriberManager; +use PHPUnit\Framework\TestCase; class SubscriberManagerTest extends TestCase { diff --git a/tests/Integration/Validator/RequestValidatorTest.php b/tests/Integration/Validator/RequestValidatorTest.php index 4b103e8..51952b4 100644 --- a/tests/Integration/Validator/RequestValidatorTest.php +++ b/tests/Integration/Validator/RequestValidatorTest.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Tests\Integration\Validator; -use PhpList\RestBundle\Entity\RequestInterface; +use PhpList\RestBundle\Entity\Request\RequestInterface; use PhpList\RestBundle\Tests\Helpers\DummyRequestDto; use PhpList\RestBundle\Validator\RequestValidator; use PHPUnit\Framework\MockObject\MockObject; From a61147e548eb99580a28a65073a2f01707f208c3 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 12 Apr 2025 22:20:40 +0400 Subject: [PATCH 24/62] ISSUE-345: create subscription endpoint --- config/services/managers.yml | 4 + config/services/normalizers.yml | 5 +- config/services/validators.yml | 10 ++ src/Controller/SessionController.php | 14 +-- src/Controller/SubscriberController.php | 27 +---- src/Controller/SubscriptionController.php | 110 ++++++++++++++++++ .../Request/CreateSubscriptionRequest.php | 21 ++++ .../SubscriptionCreationException.php | 11 ++ ...erSchemas.php => SwaggerSchemasEntity.php} | 24 +--- src/OpenApi/SwaggerSchemasResponse.php | 55 +++++++++ src/Serializer/SubscriptionNormalizer.php | 45 +++++++ src/Service/Manager/SubscriptionManager.php | 52 +++++++++ src/Validator/EmailExists.php | 22 ++++ src/Validator/EmailExistsValidator.php | 43 +++++++ src/Validator/ListExists.php | 22 ++++ src/Validator/ListExistsValidator.php | 43 +++++++ 16 files changed, 450 insertions(+), 58 deletions(-) create mode 100644 src/Controller/SubscriptionController.php create mode 100644 src/Entity/Request/CreateSubscriptionRequest.php create mode 100644 src/Exception/SubscriptionCreationException.php rename src/OpenApi/{SwaggerSchemas.php => SwaggerSchemasEntity.php} (76%) create mode 100644 src/OpenApi/SwaggerSchemasResponse.php create mode 100644 src/Serializer/SubscriptionNormalizer.php create mode 100644 src/Service/Manager/SubscriptionManager.php create mode 100644 src/Validator/EmailExists.php create mode 100644 src/Validator/EmailExistsValidator.php create mode 100644 src/Validator/ListExists.php create mode 100644 src/Validator/ListExistsValidator.php diff --git a/config/services/managers.yml b/config/services/managers.yml index f690b77..cd5617d 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -15,3 +15,7 @@ services: PhpList\RestBundle\Service\Manager\SubscriberListManager: autowire: true autoconfigure: true + + PhpList\RestBundle\Service\Manager\SubscriptionManager: + autowire: true + autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 9c9c9f7..712bed5 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -2,7 +2,6 @@ services: _defaults: autowire: true autoconfigure: true - public: false Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter: ~ @@ -22,3 +21,7 @@ services: PhpList\RestBundle\Serializer\SubscriberListNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Serializer\SubscriptionNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/config/services/validators.yml b/config/services/validators.yml index 29411cb..628b696 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -7,3 +7,13 @@ services: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Validator\EmailExistsValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Validator\ListExistsValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index 9f6ab53..24f3a1c 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -71,15 +71,7 @@ public function __construct(Authentication $authentication, SessionManager $sess new OA\Response( response: 400, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'Empty json, invalid data and or incomplete data' - ) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') ), new OA\Response( response: 401, @@ -103,7 +95,7 @@ public function createSession( $json = $normalizer->normalize($token, 'json'); - return new JsonResponse($json, Response::HTTP_CREATED, [], false); + return new JsonResponse($json, Response::HTTP_CREATED); } /** @@ -164,6 +156,6 @@ public function deleteSession( $this->sessionManager->deleteSession($token); - return new JsonResponse(null, Response::HTTP_NO_CONTENT, [], false); + return new JsonResponse(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 60fbb97..2e3773d 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -81,11 +81,7 @@ public function __construct(Authentication $authentication, SubscriberManager $s new OA\Response( response: 409, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'message', type: 'string', example: 'This resource already exists.') - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/AlreadyExistsResponse') ), new OA\Response( response: 422, @@ -187,12 +183,7 @@ public function updateSubscriber( $validator->validateDto($dto); $subscriber = $this->subscriberManager->updateSubscriber($dto); - return new JsonResponse( - $subscriberNormalizer->normalize($subscriber, 'json'), - Response::HTTP_OK, - [], - false - ); + return new JsonResponse($subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_OK); } #[Route('/{subscriberId}', name: 'get_subscriber_by_id', methods: ['GET'])] @@ -240,12 +231,7 @@ public function getSubscriber(Request $request, int $subscriberId, SubscriberNor $subscriber = $this->subscriberManager->getSubscriber($subscriberId); - return new JsonResponse( - $serializer->normalize($subscriber, 'json'), - Response::HTTP_OK, - [], - false - ); + return new JsonResponse($serializer->normalize($subscriber), Response::HTTP_OK); } #[Route('/{subscriberId}', name: 'delete_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])] @@ -294,11 +280,6 @@ public function deleteSubscriber( $this->subscriberManager->deleteSubscriber($subscriber); - return new JsonResponse( - null, - Response::HTTP_NO_CONTENT, - [], - false - ); + return new JsonResponse(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php new file mode 100644 index 0000000..cbaff81 --- /dev/null +++ b/src/Controller/SubscriptionController.php @@ -0,0 +1,110 @@ + + */ +#[Route('/subscriptions')] +class SubscriptionController extends AbstractController +{ + use AuthenticationTrait; + + private SubscriptionManager $subscriptionManager; + private RequestValidator $validator; + + public function __construct( + Authentication $authentication, + SubscriptionManager $subscriptionManager, + RequestValidator $validator + ) { + $this->authentication = $authentication; + $this->subscriptionManager = $subscriptionManager; + $this->validator = $validator; + } + + #[Route('', name: 'create_subscription', methods: ['POST'])] + #[OA\Post( + path: '/subscriptions', + description: 'Subscribe subscriber to a list.', + summary: 'Create subscription', + requestBody: new OA\RequestBody( + description: 'Pass session credentials', + required: true, + content: new OA\JsonContent( + required: ['email', 'list_id'], + properties: [ + new OA\Property(property: 'email', type: 'string', example: 'test@example.com'), + new OA\Property(property: 'list_id', type: 'integer', example: 2), + ] + ) + ), + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Subscription'), + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), + new OA\Response( + response: 409, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/AlreadyExistsResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function createSubscription(Request $request, SubscriptionNormalizer $serializer): JsonResponse + { + $this->requireAuthentication($request); + + /** @var CreateSubscriptionRequest $subscriptionRequest */ + $subscriptionRequest = $this->validator->validate($request, CreateSubscriptionRequest::class); + $subscription = $this->subscriptionManager->createSubscription( + $subscriptionRequest->email, + $subscriptionRequest->listId + ); + + return new JsonResponse($serializer->normalize($subscription, 'json'), Response::HTTP_CREATED); + } +} diff --git a/src/Entity/Request/CreateSubscriptionRequest.php b/src/Entity/Request/CreateSubscriptionRequest.php new file mode 100644 index 0000000..5ec2a4e --- /dev/null +++ b/src/Entity/Request/CreateSubscriptionRequest.php @@ -0,0 +1,21 @@ +subscriberNormalizer = $subscriberNormalizer; + $this->subscriberListNormalizer = $subscriberListNormalizer; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function normalize($object, string $format = null, array $context = []): array + { + if (!$object instanceof Subscription) { + return []; + } + + return [ + 'subscriber' => $this->subscriberNormalizer->normalize($object->getSubscriber()), + 'subscriber_list' => $this->subscriberListNormalizer->normalize($object->getSubscriberList()), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Subscription; + } +} diff --git a/src/Service/Manager/SubscriptionManager.php b/src/Service/Manager/SubscriptionManager.php new file mode 100644 index 0000000..5ac8861 --- /dev/null +++ b/src/Service/Manager/SubscriptionManager.php @@ -0,0 +1,52 @@ +subscriptionRepository = $subscriptionRepository; + $this->subscriberRepository = $subscriberRepository; + $this->subscriberListRepository = $subscriberListRepository; + } + + public function createSubscription(string $email, int $listId): Subscription + { + $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); + $subscriberList = $this->subscriberListRepository->find($listId); + + if (!$subscriber || !$subscriberList) { + throw new SubscriptionCreationException('Subscriber or list does not exists.'); + } + + $existingSubscription = $this->subscriptionRepository + ->findOneBySubscriberListAndSubscriber($subscriberList, $subscriber); + + if ($existingSubscription) { + throw new SubscriptionCreationException('Subscriber is already subscribed to this list.'); + } + $subscription = new Subscription(); + $subscription->setSubscriber($subscriber); + $subscription->setSubscriberList($subscriberList); + + $this->subscriptionRepository->save($subscription); + + return $subscription; + } +} diff --git a/src/Validator/EmailExists.php b/src/Validator/EmailExists.php new file mode 100644 index 0000000..03a6a9e --- /dev/null +++ b/src/Validator/EmailExists.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Validator/EmailExistsValidator.php b/src/Validator/EmailExistsValidator.php new file mode 100644 index 0000000..cd2e627 --- /dev/null +++ b/src/Validator/EmailExistsValidator.php @@ -0,0 +1,43 @@ +subscriberRepository = $subscriberRepository; + } + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof EmailExists) { + throw new UnexpectedTypeException($constraint, EmailExists::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + $existingUser = $this->subscriberRepository->findOneBy(['email' => $value]); + + if (!$existingUser) { + throw new ConflictHttpException('Subscriber with email does not exists.'); + } + } +} diff --git a/src/Validator/ListExists.php b/src/Validator/ListExists.php new file mode 100644 index 0000000..4fc1c10 --- /dev/null +++ b/src/Validator/ListExists.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Validator/ListExistsValidator.php b/src/Validator/ListExistsValidator.php new file mode 100644 index 0000000..dbc8750 --- /dev/null +++ b/src/Validator/ListExistsValidator.php @@ -0,0 +1,43 @@ +subscriberListRepository = $subscriberListRepository; + } + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof ListExists) { + throw new UnexpectedTypeException($constraint, ListExists::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_int($value)) { + throw new UnexpectedValueException($value, 'integer'); + } + + $list = $this->subscriberListRepository->find($value); + + if (!$list) { + throw new ConflictHttpException('Subscriber list does not exists.'); + } + } +} From 6068d250ec7210f68abeebe3a9a8eb00e7c54fca Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 13 Apr 2025 17:26:16 +0400 Subject: [PATCH 25/62] ISSUE-345: add subscription date to response --- src/OpenApi/SwaggerSchemasEntity.php | 6 ++++++ src/Serializer/SubscriberNormalizer.php | 1 + src/Serializer/SubscriptionNormalizer.php | 1 + 3 files changed, 8 insertions(+) diff --git a/src/OpenApi/SwaggerSchemasEntity.php b/src/OpenApi/SwaggerSchemasEntity.php index c097c5f..50b89e6 100644 --- a/src/OpenApi/SwaggerSchemasEntity.php +++ b/src/OpenApi/SwaggerSchemasEntity.php @@ -18,6 +18,12 @@ format: 'date-time', example: '2022-12-01T10:00:00Z' ), + new OA\Property( + property: 'subscription_date', + type: 'string', + format: 'date-time', + example: '2022-12-01T10:00:00Z' + ), new OA\Property(property: 'public', type: 'boolean', example: true), ], type: 'object' diff --git a/src/Serializer/SubscriberNormalizer.php b/src/Serializer/SubscriberNormalizer.php index 99197a8..6bb0f3c 100644 --- a/src/Serializer/SubscriberNormalizer.php +++ b/src/Serializer/SubscriberNormalizer.php @@ -36,6 +36,7 @@ public function normalize($object, string $format = null, array $context = []): 'description' => $subscription->getSubscriberList()->getDescription(), 'creation_date' => $subscription->getSubscriberList()->getCreationDate()->format('Y-m-d\TH:i:sP'), 'public' => $subscription->getSubscriberList()->isPublic(), + 'subscription_date' => $subscription->getCreationDate()->format('Y-m-d\TH:i:sP'), ]; }, $object->getSubscriptions()->toArray()), ]; diff --git a/src/Serializer/SubscriptionNormalizer.php b/src/Serializer/SubscriptionNormalizer.php index 7426efa..fbdca38 100644 --- a/src/Serializer/SubscriptionNormalizer.php +++ b/src/Serializer/SubscriptionNormalizer.php @@ -32,6 +32,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->getCreationDate()->format('Y-m-d\TH:i:sP'), ]; } From 9780fc86e6702af083f2116557445f1c2f6c9c39 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 13 Apr 2025 20:06:32 +0400 Subject: [PATCH 26/62] ISSUE-345: delete subscription endpoint --- config/services/normalizers.yml | 1 + config/services/validators.yml | 1 + src/Controller/SubscriptionController.php | 62 ++++++++++++++++++- ...ionRequest.php => SubscriptionRequest.php} | 2 +- src/EventListener/ExceptionListener.php | 6 ++ .../SubscriptionCreationException.php | 12 ++++ src/Service/Manager/SubscriptionManager.php | 15 ++++- 7 files changed, 93 insertions(+), 6 deletions(-) rename src/Entity/Request/{CreateSubscriptionRequest.php => SubscriptionRequest.php} (86%) diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 712bed5..be9c45f 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -2,6 +2,7 @@ services: _defaults: autowire: true autoconfigure: true + public: false Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter: ~ diff --git a/config/services/validators.yml b/config/services/validators.yml index 628b696..33ed74a 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -2,6 +2,7 @@ services: PhpList\RestBundle\Validator\RequestValidator: autowire: true autoconfigure: true + public: false PhpList\RestBundle\Validator\UniqueEmailValidator: autowire: true diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index cbaff81..1ecc96f 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -7,7 +7,8 @@ use OpenApi\Attributes as OA; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; -use PhpList\RestBundle\Entity\Request\CreateSubscriptionRequest; +use PhpList\RestBundle\Entity\Request\SubscriptionRequest; +use PhpList\RestBundle\Entity\Request\DeleteSubscriptionRequest; use PhpList\RestBundle\Serializer\SubscriptionNormalizer; use PhpList\RestBundle\Service\Manager\SubscriptionManager; use PhpList\RestBundle\Validator\RequestValidator; @@ -98,8 +99,8 @@ public function createSubscription(Request $request, SubscriptionNormalizer $ser { $this->requireAuthentication($request); - /** @var CreateSubscriptionRequest $subscriptionRequest */ - $subscriptionRequest = $this->validator->validate($request, CreateSubscriptionRequest::class); + /** @var SubscriptionRequest $subscriptionRequest */ + $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); $subscription = $this->subscriptionManager->createSubscription( $subscriptionRequest->email, $subscriptionRequest->listId @@ -107,4 +108,59 @@ public function createSubscription(Request $request, SubscriptionNormalizer $ser return new JsonResponse($serializer->normalize($subscription, 'json'), Response::HTTP_CREATED); } + + #[Route('', name: 'delete_subscription', methods: ['DELETE'])] + #[OA\Delete( + path: '/subscriptions', + description: 'Delete subscription.', + summary: 'Delete subscription', + requestBody: new OA\RequestBody( + description: 'Pass session credentials', + required: true, + content: new OA\JsonContent( + required: ['email', 'list_id'], + properties: [ + new OA\Property(property: 'email', type: 'string', example: 'test@example.com'), + new OA\Property(property: 'list_id', type: 'integer', example: 2), + ] + ) + ), + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 204, + description: 'Success', + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Not Found', + ) + ] + )] + public function deleteSubscriber( + Request $request, + ): JsonResponse { + $this->requireAuthentication($request); + + /** @var SubscriptionRequest $subscriptionRequest */ + $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); + + $this->subscriptionManager->deleteSubscription($subscriptionRequest->email, $subscriptionRequest->listId); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } } diff --git a/src/Entity/Request/CreateSubscriptionRequest.php b/src/Entity/Request/SubscriptionRequest.php similarity index 86% rename from src/Entity/Request/CreateSubscriptionRequest.php rename to src/Entity/Request/SubscriptionRequest.php index 5ec2a4e..d7960ee 100644 --- a/src/Entity/Request/CreateSubscriptionRequest.php +++ b/src/Entity/Request/SubscriptionRequest.php @@ -7,7 +7,7 @@ use PhpList\RestBundle\Validator as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; -class CreateSubscriptionRequest implements RequestInterface +class SubscriptionRequest implements RequestInterface { #[Assert\NotBlank] #[Assert\Email] diff --git a/src/EventListener/ExceptionListener.php b/src/EventListener/ExceptionListener.php index 8cd80b9..1dc4662 100644 --- a/src/EventListener/ExceptionListener.php +++ b/src/EventListener/ExceptionListener.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\EventListener; use Exception; +use PhpList\RestBundle\Exception\SubscriptionCreationException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -27,6 +28,11 @@ public function onKernelException(ExceptionEvent $event): void 'message' => $exception->getMessage(), ], $exception->getStatusCode()); + $event->setResponse($response); + } elseif ($exception instanceof SubscriptionCreationException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], $exception->getStatusCode()); $event->setResponse($response); } elseif ($exception instanceof Exception) { $response = new JsonResponse([ diff --git a/src/Exception/SubscriptionCreationException.php b/src/Exception/SubscriptionCreationException.php index 201150f..4628421 100644 --- a/src/Exception/SubscriptionCreationException.php +++ b/src/Exception/SubscriptionCreationException.php @@ -8,4 +8,16 @@ class SubscriptionCreationException extends RuntimeException { + private int $statusCode; + + public function __construct(string $message, int $statusCode) + { + parent::__construct($message); + $this->statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } } diff --git a/src/Service/Manager/SubscriptionManager.php b/src/Service/Manager/SubscriptionManager.php index 5ac8861..64e3dec 100644 --- a/src/Service/Manager/SubscriptionManager.php +++ b/src/Service/Manager/SubscriptionManager.php @@ -32,14 +32,14 @@ public function createSubscription(string $email, int $listId): Subscription $subscriberList = $this->subscriberListRepository->find($listId); if (!$subscriber || !$subscriberList) { - throw new SubscriptionCreationException('Subscriber or list does not exists.'); + throw new SubscriptionCreationException('Subscriber or list does not exists.', 404); } $existingSubscription = $this->subscriptionRepository ->findOneBySubscriberListAndSubscriber($subscriberList, $subscriber); if ($existingSubscription) { - throw new SubscriptionCreationException('Subscriber is already subscribed to this list.'); + throw new SubscriptionCreationException('Subscriber is already subscribed to this list.', 409); } $subscription = new Subscription(); $subscription->setSubscriber($subscriber); @@ -49,4 +49,15 @@ public function createSubscription(string $email, int $listId): Subscription return $subscription; } + + public function deleteSubscription(string $email, int $listId): void + { + $subscription = $this->subscriptionRepository->findOneBySubscriberEmailAndListId($listId, $email); + + if (!$subscription) { + throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404); + } + + $this->subscriptionRepository->remove($subscription); + } } From 8d636dc6b3e2b64f4e986a555aca39233c99b4c7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 13 Apr 2025 20:13:34 +0400 Subject: [PATCH 27/62] ISSUE-345: test fix --- tests/Integration/Controller/ListControllerTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index bb1313b..627014a 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -254,6 +254,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'description' => '', 'creation_date' => '2016-06-22T15:01:17+00:00', 'public' => true, + 'subscription_date' => '2016-07-22T15:01:17+00:00', ], ], ], [ @@ -273,6 +274,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'description' => '', 'creation_date' => '2016-06-22T15:01:17+00:00', 'public' => true, + 'subscription_date' => '2016-08-22T15:01:17+00:00', ], [ 'id' => 1, @@ -280,6 +282,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'description' => 'News (and some fun stuff)', 'creation_date' => '2016-06-22T15:01:17+00:00', 'public' => true, + 'subscription_date' => '2016-09-22T15:01:17+00:00', ], ], ], From a1c72ffc076b0d1377396a9c0dc4aa18be4c7f83 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 13 Apr 2025 21:30:23 +0400 Subject: [PATCH 28/62] ISSUE-345: refactor subscription --- config/services/validators.yml | 5 - src/Controller/ListController.php | 110 ---------- src/Controller/SubscriptionController.php | 197 +++++++++++++++--- src/Entity/Request/SubscriptionRequest.php | 17 +- src/OpenApi/SwaggerSchemasEntity.php | 16 +- src/Serializer/SubscriberNormalizer.php | 2 +- src/Service/Manager/SubscriberListManager.php | 11 - src/Service/Manager/SubscriptionManager.php | 55 +++-- src/Validator/ListExists.php | 22 -- src/Validator/ListExistsValidator.php | 43 ---- .../Controller/ListControllerTest.php | 4 +- 11 files changed, 231 insertions(+), 251 deletions(-) delete mode 100644 src/Validator/ListExists.php delete mode 100644 src/Validator/ListExistsValidator.php diff --git a/config/services/validators.yml b/config/services/validators.yml index 33ed74a..5df52ea 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -13,8 +13,3 @@ services: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] - - PhpList\RestBundle\Validator\ListExistsValidator: - autowire: true - autoconfigure: true - tags: [ 'validator.constraint_validator' ] diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 7b749db..ef7f177 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -10,7 +10,6 @@ use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; use PhpList\RestBundle\Serializer\SubscriberListNormalizer; -use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Service\Manager\SubscriberListManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -243,115 +242,6 @@ public function deleteList( return new JsonResponse(null, Response::HTTP_NO_CONTENT); } - #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] - #[OA\Get( - path: '/lists/{listId}/subscribers', - description: 'Returns a JSON list of all subscribers for a subscriber list.', - summary: 'Gets a list of all subscribers of a subscriber list.', - tags: ['lists'], - parameters: [ - new OA\Parameter( - name: 'session', - description: 'Session ID obtained from authentication', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'listId', - description: 'List ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Subscriber') - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function getListMembers( - Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, - SubscriberNormalizer $normalizer - ): JsonResponse { - $this->requireAuthentication($request); - - $subscribers = $this->subscriberListManager->getSubscriberListMembers($list); - $normalized = array_map(function ($item) use ($normalizer) { - return $normalizer->normalize($item); - }, $subscribers); - - return new JsonResponse($normalized, Response::HTTP_OK); - } - - #[Route('/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] - #[OA\Get( - path: '/lists/{listId}/count', - description: 'Returns a count of all subscribers in a given list.', - summary: 'Gets the total number of subscribers of a list', - tags: ['lists'], - parameters: [ - new OA\Parameter( - name: 'session', - description: 'Session ID obtained from authentication', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'listId', - description: 'List ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'subscribers_count', - type: 'integer', - example: 42 - ) - ], - type: 'object' - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function getSubscribersCount( - Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list - ): JsonResponse { - $this->requireAuthentication($request); - - return new JsonResponse( - ['subscribers_count' => count($list->getSubscribers())], - Response::HTTP_OK, - ); - } - #[Route('', name: 'create_list', methods: ['POST'])] #[OA\Post( path: '/lists', diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index 1ecc96f..1a2f326 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -5,13 +5,15 @@ namespace PhpList\RestBundle\Controller; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\SubscriptionRequest; -use PhpList\RestBundle\Entity\Request\DeleteSubscriptionRequest; +use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Serializer\SubscriptionNormalizer; use PhpList\RestBundle\Service\Manager\SubscriptionManager; use PhpList\RestBundle\Validator\RequestValidator; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -23,7 +25,7 @@ * * @author Tatevik Grigoryan */ -#[Route('/subscriptions')] +#[Route('/lists')] class SubscriptionController extends AbstractController { use AuthenticationTrait; @@ -41,19 +43,129 @@ public function __construct( $this->validator = $validator; } - #[Route('', name: 'create_subscription', methods: ['POST'])] + #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] + #[OA\Get( + path: '/lists/{listId}/subscribers', + description: 'Returns a JSON list of all subscribers for a subscriber list.', + summary: 'Gets a list of all subscribers of a subscriber list.', + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Subscriber') + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getListMembers( + Request $request, + #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, + SubscriberNormalizer $normalizer + ): JsonResponse { + $this->requireAuthentication($request); + + $subscribers = $this->subscriptionManager->getSubscriberListMembers($list); + $normalized = array_map(function ($item) use ($normalizer) { + return $normalizer->normalize($item); + }, $subscribers); + + return new JsonResponse($normalized, Response::HTTP_OK); + } + + #[Route('/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] + #[OA\Get( + path: '/lists/{listId}/count', + description: 'Returns a count of all subscribers in a given list.', + summary: 'Gets the total number of subscribers of a list', + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'subscribers_count', + type: 'integer', + example: 42 + ) + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getSubscribersCount( + Request $request, + #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list + ): JsonResponse { + $this->requireAuthentication($request); + + return new JsonResponse(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); + } + + #[Route('/{listId}/subscribers', name: 'create_subscription', methods: ['POST'])] #[OA\Post( - path: '/subscriptions', + path: '/lists/{listId}/subscribers', description: 'Subscribe subscriber to a list.', summary: 'Create subscription', requestBody: new OA\RequestBody( description: 'Pass session credentials', required: true, content: new OA\JsonContent( - required: ['email', 'list_id'], + required: ['emails'], properties: [ - new OA\Property(property: 'email', type: 'string', example: 'test@example.com'), - new OA\Property(property: 'list_id', type: 'integer', example: 2), + new OA\Property( + property: 'emails', + type: 'array', + items: new OA\Items(type: 'string', format: 'email'), + example: ['test1@example.com', 'test2@example.com'] + ), ] ) ), @@ -65,13 +177,23 @@ public function __construct( in: 'header', required: true, schema: new OA\Schema(type: 'string') - ) + ), + new OA\Parameter( + name: 'listId', + description: 'List 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/Subscription'), + content: new OA\JsonContent( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Subscription') + ) ), new OA\Response( response: 403, @@ -95,36 +217,29 @@ public function __construct( ), ] )] - public function createSubscription(Request $request, SubscriptionNormalizer $serializer): JsonResponse - { + public function createSubscription( + Request $request, + #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, + SubscriptionNormalizer $serializer + ): JsonResponse { $this->requireAuthentication($request); /** @var SubscriptionRequest $subscriptionRequest */ $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); - $subscription = $this->subscriptionManager->createSubscription( - $subscriptionRequest->email, - $subscriptionRequest->listId - ); + $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); - return new JsonResponse($serializer->normalize($subscription, 'json'), Response::HTTP_CREATED); + $normalized = array_map(function ($item) use ($serializer) { + return $serializer->normalize($item); + }, $subscriptions); + + return new JsonResponse($normalized, Response::HTTP_CREATED); } - #[Route('', name: 'delete_subscription', methods: ['DELETE'])] + #[Route('/{listId}/subscribers', name: 'delete_subscription', methods: ['DELETE'])] #[OA\Delete( - path: '/subscriptions', + path: '/lists/{listId}/subscribers', description: 'Delete subscription.', summary: 'Delete subscription', - requestBody: new OA\RequestBody( - description: 'Pass session credentials', - required: true, - content: new OA\JsonContent( - required: ['email', 'list_id'], - properties: [ - new OA\Property(property: 'email', type: 'string', example: 'test@example.com'), - new OA\Property(property: 'list_id', type: 'integer', example: 2), - ] - ) - ), tags: ['subscriptions'], parameters: [ new OA\Parameter( @@ -134,6 +249,20 @@ public function createSubscription(Request $request, SubscriptionNormalizer $ser required: true, schema: new OA\Schema(type: 'string') ), + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'emails', + description: 'emails of subscribers to delete from list.', + in: 'query', + required: true, + schema: new OA\Schema(type: 'string') + ), ], responses: [ new OA\Response( @@ -147,19 +276,21 @@ public function createSubscription(Request $request, SubscriptionNormalizer $ser ), new OA\Response( response: 404, - description: 'Not Found', + description: 'Subscriber or subscription not found.' ) ] )] public function deleteSubscriber( Request $request, + #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, ): JsonResponse { $this->requireAuthentication($request); + $subscriptionRequest = new SubscriptionRequest(); + $subscriptionRequest->emails = $request->query->all('emails'); /** @var SubscriptionRequest $subscriptionRequest */ - $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); - - $this->subscriptionManager->deleteSubscription($subscriptionRequest->email, $subscriptionRequest->listId); + $subscriptionRequest = $this->validator->validateDto($subscriptionRequest); + $this->subscriptionManager->deleteSubscriptions($list, $subscriptionRequest->emails); return new JsonResponse(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Entity/Request/SubscriptionRequest.php b/src/Entity/Request/SubscriptionRequest.php index d7960ee..81aebf0 100644 --- a/src/Entity/Request/SubscriptionRequest.php +++ b/src/Entity/Request/SubscriptionRequest.php @@ -4,18 +4,17 @@ namespace PhpList\RestBundle\Entity\Request; -use PhpList\RestBundle\Validator as CustomAssert; +use PhpList\RestBundle\Validator\EmailExists; use Symfony\Component\Validator\Constraints as Assert; class SubscriptionRequest implements RequestInterface { #[Assert\NotBlank] - #[Assert\Email] - #[CustomAssert\EmailExists] - public string $email; - - #[Assert\NotBlank] - #[Assert\NotNull] - #[CustomAssert\ListExists] - public int $listId; + #[Assert\Type('array')] + #[Assert\All([ + new Assert\NotBlank(), + new Assert\Email(), + new EmailExists() + ])] + public array $emails = []; } diff --git a/src/OpenApi/SwaggerSchemasEntity.php b/src/OpenApi/SwaggerSchemasEntity.php index 50b89e6..205cb10 100644 --- a/src/OpenApi/SwaggerSchemasEntity.php +++ b/src/OpenApi/SwaggerSchemasEntity.php @@ -46,13 +46,27 @@ new OA\Property(property: 'html_email', type: 'boolean', example: true), new OA\Property(property: 'disabled', type: 'boolean', example: false), new OA\Property( - property: 'subscribedLists', + property: 'subscribed_lists', type: 'array', items: new OA\Items(ref: '#/components/schemas/SubscriberList') ), ], type: 'object' )] +#[OA\Schema( + schema: 'Subscription', + properties: [ + new OA\Property(property: 'subscriber', ref: '#/components/schemas/Subscriber'), + new OA\Property(property: 'subscriber_list', ref: '#/components/schemas/SubscriberList'), + new OA\Property( + property: 'subscription_date', + type: 'string', + format: 'date-time', + example: '2023-01-01T12:00:00Z', + ), + ], + type: 'object' +)] class SwaggerSchemasEntity { } diff --git a/src/Serializer/SubscriberNormalizer.php b/src/Serializer/SubscriberNormalizer.php index 6bb0f3c..fbde5ce 100644 --- a/src/Serializer/SubscriberNormalizer.php +++ b/src/Serializer/SubscriberNormalizer.php @@ -29,7 +29,7 @@ public function normalize($object, string $format = null, array $context = []): 'unique_id' => $object->getUniqueId(), 'html_email' => $object->hasHtmlEmail(), 'disabled' => $object->isDisabled(), - 'subscribedLists' => array_map(function (Subscription $subscription) { + 'subscribed_lists' => array_map(function (Subscription $subscription) { return [ 'id' => $subscription->getSubscriberList()->getId(), 'name' => $subscription->getSubscriberList()->getName(), diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php index 1c23df7..809aa99 100644 --- a/src/Service/Manager/SubscriberListManager.php +++ b/src/Service/Manager/SubscriberListManager.php @@ -4,23 +4,18 @@ namespace PhpList\RestBundle\Service\Manager; -use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; class SubscriberListManager { private SubscriberListRepository $subscriberListRepository; - private SubscriberRepository $subscriberRepository; public function __construct( SubscriberListRepository $subscriberListRepository, - SubscriberRepository $subscriberRepository ) { $this->subscriberListRepository = $subscriberListRepository; - $this->subscriberRepository = $subscriberRepository; } public function createSubscriberList(CreateSubscriberListRequest $subscriberListRequest): SubscriberList @@ -46,10 +41,4 @@ public function delete(SubscriberList $subscriberList): void { $this->subscriberListRepository->remove($subscriberList); } - - /** @return Subscriber[] */ - public function getSubscriberListMembers(SubscriberList $list): array - { - return $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); - } } diff --git a/src/Service/Manager/SubscriptionManager.php b/src/Service/Manager/SubscriptionManager.php index 64e3dec..ab410ea 100644 --- a/src/Service/Manager/SubscriptionManager.php +++ b/src/Service/Manager/SubscriptionManager.php @@ -4,8 +4,9 @@ namespace PhpList\RestBundle\Service\Manager; +use PhpList\Core\Domain\Model\Subscription\Subscriber; +use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Model\Subscription\Subscription; -use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository; use PhpList\RestBundle\Exception\SubscriptionCreationException; @@ -14,33 +15,39 @@ class SubscriptionManager { private SubscriptionRepository $subscriptionRepository; private SubscriberRepository $subscriberRepository; - private SubscriberListRepository $subscriberListRepository; public function __construct( SubscriptionRepository $subscriptionRepository, - SubscriberRepository $subscriberRepository, - SubscriberListRepository $subscriberListRepository + SubscriberRepository $subscriberRepository ) { $this->subscriptionRepository = $subscriptionRepository; $this->subscriberRepository = $subscriberRepository; - $this->subscriberListRepository = $subscriberListRepository; } - public function createSubscription(string $email, int $listId): Subscription + /** @return Subscription[] */ + public function createSubscriptions(SubscriberList $subscriberList, array $emails): array { - $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); - $subscriberList = $this->subscriberListRepository->find($listId); + $subscriptions = []; + foreach ($emails as $email) { + $subscriptions[] = $this->createSubscription($subscriberList, $email); + } + + return $subscriptions; + } - if (!$subscriber || !$subscriberList) { - throw new SubscriptionCreationException('Subscriber or list does not exists.', 404); + private function createSubscription(SubscriberList $subscriberList, string $email): Subscription + { + $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); + if (!$subscriber) { + throw new SubscriptionCreationException('Subscriber does not exists.', 404); } $existingSubscription = $this->subscriptionRepository ->findOneBySubscriberListAndSubscriber($subscriberList, $subscriber); - if ($existingSubscription) { - throw new SubscriptionCreationException('Subscriber is already subscribed to this list.', 409); + return $existingSubscription; } + $subscription = new Subscription(); $subscription->setSubscriber($subscriber); $subscription->setSubscriberList($subscriberList); @@ -50,9 +57,23 @@ public function createSubscription(string $email, int $listId): Subscription return $subscription; } - public function deleteSubscription(string $email, int $listId): void + public function deleteSubscriptions(SubscriberList $subscriberList, array $emails): void { - $subscription = $this->subscriptionRepository->findOneBySubscriberEmailAndListId($listId, $email); + foreach ($emails as $email) { + try { + $this->deleteSubscription($subscriberList, $email); + } catch (SubscriptionCreationException $e) { + if ($e->getStatusCode() !== 404) { + throw $e; + } + } + } + } + + private function deleteSubscription(SubscriberList $subscriberList, string $email): void + { + $subscription = $this->subscriptionRepository + ->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email); if (!$subscription) { throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404); @@ -60,4 +81,10 @@ public function deleteSubscription(string $email, int $listId): void $this->subscriptionRepository->remove($subscription); } + + /** @return Subscriber[] */ + public function getSubscriberListMembers(SubscriberList $list): array + { + return $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); + } } diff --git a/src/Validator/ListExists.php b/src/Validator/ListExists.php deleted file mode 100644 index 4fc1c10..0000000 --- a/src/Validator/ListExists.php +++ /dev/null @@ -1,22 +0,0 @@ -mode = $mode ?? $this->mode; - $this->message = $message ?? $this->message; - } -} diff --git a/src/Validator/ListExistsValidator.php b/src/Validator/ListExistsValidator.php deleted file mode 100644 index dbc8750..0000000 --- a/src/Validator/ListExistsValidator.php +++ /dev/null @@ -1,43 +0,0 @@ -subscriberListRepository = $subscriberListRepository; - } - - public function validate($value, Constraint $constraint): void - { - if (!$constraint instanceof ListExists) { - throw new UnexpectedTypeException($constraint, ListExists::class); - } - - if (null === $value || '' === $value) { - return; - } - - if (!is_int($value)) { - throw new UnexpectedValueException($value, 'integer'); - } - - $list = $this->subscriberListRepository->find($value); - - if (!$list) { - throw new ConflictHttpException('Subscriber list does not exists.'); - } - } -} diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index 627014a..8c3825b 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -247,7 +247,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e89', 'html_email' => true, 'disabled' => true, - 'subscribedLists' => [ + 'subscribed_lists' => [ [ 'id' => 2, 'name' => 'More news', @@ -267,7 +267,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e87', 'html_email' => true, 'disabled' => true, - 'subscribedLists' => [ + 'subscribed_lists' => [ [ 'id' => 2, 'name' => 'More news', From 46beb03680adc0d9342944498458857f839b1791 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 13 Apr 2025 22:19:48 +0400 Subject: [PATCH 29/62] ISSUE-345: remove from list --- src/Controller/SubscriptionController.php | 2 +- src/OpenApi/SwaggerSchemasEntity.php | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index 1a2f326..3e1ef36 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -280,7 +280,7 @@ public function createSubscription( ) ] )] - public function deleteSubscriber( + public function deleteSubscriptions( Request $request, #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, ): JsonResponse { diff --git a/src/OpenApi/SwaggerSchemasEntity.php b/src/OpenApi/SwaggerSchemasEntity.php index 205cb10..dbc7dff 100644 --- a/src/OpenApi/SwaggerSchemasEntity.php +++ b/src/OpenApi/SwaggerSchemasEntity.php @@ -18,12 +18,6 @@ format: 'date-time', example: '2022-12-01T10:00:00Z' ), - new OA\Property( - property: 'subscription_date', - type: 'string', - format: 'date-time', - example: '2022-12-01T10:00:00Z' - ), new OA\Property(property: 'public', type: 'boolean', example: true), ], type: 'object' From 3cec7c9534dad30eb256189605a7e5bc06c20c8d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 17 Apr 2025 13:10:40 +0400 Subject: [PATCH 30/62] ISSUE-345: get campaigns endpoint --- config/services/normalizers.yml | 4 ++ config/services/providers.yml | 9 +++ src/Controller/CampaignController.php | 89 ++++++++++++++++++++++++ src/OpenApi/SwaggerSchemasEntity.php | 80 +++++++++++++++++++++ src/Serializer/MessageNormalizer.php | 77 ++++++++++++++++++++ src/Service/Provider/MessageProvider.php | 26 +++++++ 6 files changed, 285 insertions(+) create mode 100644 config/services/providers.yml create mode 100644 src/Controller/CampaignController.php create mode 100644 src/Serializer/MessageNormalizer.php create mode 100644 src/Service/Provider/MessageProvider.php diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index be9c45f..521d52d 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -26,3 +26,7 @@ services: PhpList\RestBundle\Serializer\SubscriptionNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Serializer\MessageNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/config/services/providers.yml b/config/services/providers.yml new file mode 100644 index 0000000..240bc00 --- /dev/null +++ b/config/services/providers.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Service\Provider\MessageProvider: + autowire: true + autoconfigure: true diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php new file mode 100644 index 0000000..c9e132e --- /dev/null +++ b/src/Controller/CampaignController.php @@ -0,0 +1,89 @@ + + */ +#[Route('/campaigns')] +class CampaignController extends AbstractController +{ + use AuthenticationTrait; + + private MessageProvider $messageProvider; + private RequestValidator $validator; + private MessageNormalizer $normalizer; + + public function __construct( + Authentication $authentication, + MessageProvider $messageProvider, + RequestValidator $validator, + MessageNormalizer $normalizer + ) { + $this->authentication = $authentication; + $this->messageProvider = $messageProvider; + $this->validator = $validator; + $this->normalizer = $normalizer; + } + + #[Route('', name: 'get_campaigns', methods: ['GET'])] + #[OA\Get( + path: '/campaigns', + description: 'Returns a JSON list of all campaigns/messages.', + summary: 'Gets a list of all campaigns.', + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Message') + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getMessages(Request $request): JsonResponse + { + $authUer = $this->requireAuthentication($request); + $data = $this->messageProvider->getMessagesByOwner($authUer); + + $normalized = array_map(function ($item) { + return $this->normalizer->normalize($item); + }, $data); + + return new JsonResponse($normalized, Response::HTTP_OK); + } +} diff --git a/src/OpenApi/SwaggerSchemasEntity.php b/src/OpenApi/SwaggerSchemasEntity.php index dbc7dff..c0b5bee 100644 --- a/src/OpenApi/SwaggerSchemasEntity.php +++ b/src/OpenApi/SwaggerSchemasEntity.php @@ -61,6 +61,86 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'Template', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'title', type: 'string', example: 'Newsletter'), + new OA\Property(property: 'template', type: 'string', example: 'Hello World!', nullable: true), + new OA\Property(property: 'template_text', type: 'string', nullable: true), + new OA\Property(property: 'order', type: 'integer', nullable: true), + ], + type: 'object', + nullable: true +)] +#[OA\Schema( + schema: 'Message', + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'unique_id', type: 'string', example: '2df6b147-8470-45ed-8e4e-86aa01af400d'), + new OA\Property( + property: 'template', + ref: '#/components/schemas/Template', + nullable: true + ), + new OA\Property( + property: 'message_content', + properties: [ + new OA\Property(property: 'subject', type: 'string', example: 'Newsletter'), + new OA\Property(property: 'text', type: 'string', example: 'Hello World!'), + new OA\Property(property: 'text_message', type: 'string'), + new OA\Property(property: 'footer', type: 'string', example: 'This is a footer'), + ], + type: 'object' + ), + new OA\Property( + property: 'message_format', + properties: [ + new OA\Property(property: 'html_formated', type: 'boolean'), + new OA\Property(property: 'send_format', type: 'string', example: 'text', nullable: true), + new OA\Property(property: 'as_text', type: 'boolean', example: true), + new OA\Property(property: 'as_html', type: 'boolean'), + new OA\Property(property: 'as_pdf', type: 'boolean'), + new OA\Property(property: 'as_text_and_html', type: 'boolean'), + new OA\Property(property: 'as_text_and_pdf', type: 'boolean'), + ], + type: 'object' + ), + new OA\Property( + property: 'message_metadata', + properties: [ + new OA\Property(property: 'status', type: 'string', example: 'sent'), + new OA\Property(property: 'processed', type: 'bool', example: true), + new OA\Property(property: 'views', type: 'integer', example: 12), + new OA\Property(property: 'bounce_count', type: 'integer'), + new OA\Property(property: 'entered', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'sent', type: 'string', format: 'date-time', nullable: true), + ], + type: 'object' + ), + new OA\Property( + property: 'message_schedule', + properties: [ + new OA\Property(property: 'repeat_interval', type: 'string', nullable: true), + new OA\Property(property: 'repeat_until', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'requeue_interval', type: 'string', nullable: true), + new OA\Property(property: 'requeue_until', type: 'string', format: 'date-time', nullable: true), + ], + type: 'object' + ), + new OA\Property( + property: 'message_options', + properties: [ + new OA\Property(property: 'from_field', type: 'string', example: ' My Name ', nullable: true), + new OA\Property(property: 'to_field', type: 'string', example: '', nullable: true), + new OA\Property(property: 'reply_to', type: 'string', nullable: true), + new OA\Property(property: 'embargo', type: 'string', example: '2023-01-01T12:00:00Z', nullable: true), + new OA\Property(property: 'user_selection', type: 'string', nullable: true), + ], + type: 'object'), + ], + type: 'object' +)] class SwaggerSchemasEntity { } diff --git a/src/Serializer/MessageNormalizer.php b/src/Serializer/MessageNormalizer.php new file mode 100644 index 0000000..1723a71 --- /dev/null +++ b/src/Serializer/MessageNormalizer.php @@ -0,0 +1,77 @@ + $object->getId(), + 'unique_id' => $object->getUuid(), + 'template' => $object->getTemplate()?->getId() ? [ + 'id' => $object->getTemplate()->getId(), + 'title' => $object->getTemplate()->getTitle(), + 'template' => $object->getTemplate()->getTemplate(), + 'template_text' => $object->getTemplate()->getTemplateText(), + 'order' => $object->getTemplate()->getListOrder(), + ] : null, + 'message_content' => [ + 'subject' => $object->getContent()->getSubject(), + 'text' => $object->getContent()->getText(), + 'text_message' => $object->getContent()->getTextMessage(), + 'footer' => $object->getContent()->getFooter(), + ], + 'message_format' => [ + 'html_formated' => $object->getFormat()->isHtmlFormatted(), + 'send_format' => $object->getFormat()->getSendFormat(), + 'as_text' => $object->getFormat()->isAsText(), + 'as_html' => $object->getFormat()->isAsHtml(), + 'as_pdf' => $object->getFormat()->isAsPdf(), + 'as_text_and_html' => $object->getFormat()->isAsTextAndHtml(), + 'as_text_and_pdf' => $object->getFormat()->isAsTextAndPdf(), + ], + 'message_metadata' => [ + 'status' => $object->getMetadata()->getStatus(), + 'processed' => $object->getMetadata()->isProcessed(), + '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'), + ], + 'message_schedule' => [ + 'repeat_interval' => $object->getSchedule()->getRepeatInterval(), + 'repeat_until' => $object->getSchedule()->getRepeatUntil()?->format('Y-m-d\TH:i:sP'), + 'requeue_interval' => $object->getSchedule()->getRequeueInterval(), + 'requeue_until' => $object->getSchedule()->getRequeueUntil()?->format('Y-m-d\TH:i:sP'), + ], + 'message_options' => [ + 'from_field' => $object->getOptions()->getFromField(), + 'to_field' => $object->getOptions()->getToField(), + 'reply_to' => $object->getOptions()->getReplyTo(), + 'embargo' => $object->getOptions()->getEmbargo(), + 'user_selection' => $object->getOptions()->getUserSelection(), + ], + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Message; + } +} diff --git a/src/Service/Provider/MessageProvider.php b/src/Service/Provider/MessageProvider.php new file mode 100644 index 0000000..dce8341 --- /dev/null +++ b/src/Service/Provider/MessageProvider.php @@ -0,0 +1,26 @@ +messageRepository = $messageRepository; + } + + /** @return Message[] */ + public function getMessagesByOwner(Administrator $owner): array + { + return $this->messageRepository->getByOwnerId($owner->getId()); + } +} From 3c3df09c51f09d90ef32234b1778ec1266ec9b3c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 17 Apr 2025 13:16:01 +0400 Subject: [PATCH 31/62] ISSUE-345: update runner --- .github/workflows/ci.yml | 2 +- .github/workflows/restapi-docs.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffb8533..3ad98ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ on: [push, pull_request] jobs: main: name: phpList Base Dist on PHP ${{ matrix.php-versions }}, with dist ${{ matrix.dependencies }} [Build, Test] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 env: DB_DATABASE: phplist DB_USERNAME: root diff --git a/.github/workflows/restapi-docs.yml b/.github/workflows/restapi-docs.yml index 932776a..b5ffc02 100644 --- a/.github/workflows/restapi-docs.yml +++ b/.github/workflows/restapi-docs.yml @@ -8,7 +8,7 @@ on: jobs: make-restapi-docs: name: Checkout phpList rest-api and generate docs specification (OpenAPI latest-restapi.json) - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout Repository uses: actions/checkout@v3 @@ -41,7 +41,7 @@ jobs: deploy-docs: name: Deploy REST API Specification - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: make-restapi-docs steps: - name: Setup Node.js From b90c43b0853727487533163cfdf3aaf5fac81149 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 19 Apr 2025 14:51:18 +0400 Subject: [PATCH 32/62] ISSUE-345: create message endpoint --- config/services/managers.yml | 4 + src/Controller/CampaignController.php | 131 +++++++++++++++++- src/Controller/ListController.php | 7 +- src/Entity/Request/CreateMessageRequest.php | 35 +++++ .../Request/Message/MessageContentRequest.php | 22 +++ .../Request/Message/MessageFormatRequest.php | 22 +++ .../Message/MessageMetadataRequest.php | 13 ++ .../Request/Message/MessageOptionsRequest.php | 24 ++++ .../Message/MessageScheduleRequest.php | 22 +++ src/OpenApi/SwaggerSchemasEntity.php | 10 +- src/Serializer/MessageNormalizer.php | 14 +- src/Service/Manager/MessageManager.php | 62 +++++++++ 12 files changed, 355 insertions(+), 11 deletions(-) create mode 100644 src/Entity/Request/CreateMessageRequest.php create mode 100644 src/Entity/Request/Message/MessageContentRequest.php create mode 100644 src/Entity/Request/Message/MessageFormatRequest.php create mode 100644 src/Entity/Request/Message/MessageMetadataRequest.php create mode 100644 src/Entity/Request/Message/MessageOptionsRequest.php create mode 100644 src/Entity/Request/Message/MessageScheduleRequest.php create mode 100644 src/Service/Manager/MessageManager.php diff --git a/config/services/managers.yml b/config/services/managers.yml index cd5617d..3665728 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -19,3 +19,7 @@ services: PhpList\RestBundle\Service\Manager\SubscriptionManager: autowire: true autoconfigure: true + + PhpList\RestBundle\Service\Manager\MessageManager: + autowire: true + autoconfigure: true diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index c9e132e..271e1e2 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -7,7 +7,9 @@ use OpenApi\Attributes as OA; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; +use PhpList\RestBundle\Entity\Request\CreateMessageRequest; use PhpList\RestBundle\Serializer\MessageNormalizer; +use PhpList\RestBundle\Service\Manager\MessageManager; use PhpList\RestBundle\Service\Provider\MessageProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route; /** - * This controller provides REST API manage campaigns. + * This controller provides REST API to manage campaigns. * * @author Tatevik Grigoryan */ @@ -29,17 +31,20 @@ class CampaignController extends AbstractController private MessageProvider $messageProvider; private RequestValidator $validator; private MessageNormalizer $normalizer; + private MessageManager $messageManager; public function __construct( Authentication $authentication, MessageProvider $messageProvider, RequestValidator $validator, - MessageNormalizer $normalizer + MessageNormalizer $normalizer, + MessageManager $messageManager ) { $this->authentication = $authentication; $this->messageProvider = $messageProvider; $this->validator = $validator; $this->normalizer = $normalizer; + $this->messageManager = $messageManager; } #[Route('', name: 'get_campaigns', methods: ['GET'])] @@ -86,4 +91,126 @@ public function getMessages(Request $request): JsonResponse return new JsonResponse($normalized, Response::HTTP_OK); } + + #[Route('', name: 'create_message', methods: ['POST'])] + #[OA\Post( + path: '/campaigns', + description: 'Returns created message.', + summary: 'Create a message for campaign.', + requestBody: new OA\RequestBody( + description: 'Create a new message.', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'message_content', + 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' + ), + new OA\Property( + property: 'message_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' + ), + new OA\Property( + property: 'message_metadata', + properties: [ + new OA\Property(property: 'status', type: 'string', example: 'draft'), + ], + type: 'object' + ), + new OA\Property( + property: 'message_schedule', + properties: [ + new OA\Property(property: 'repeat_interval', type: 'string', example: '24 hours'), + new OA\Property( + property: 'repeat_until', + type: 'string', + format: 'date-time', + example: '2025-04-30T00:00:00+04:00' + ), + new OA\Property(property: 'requeue_interval', type: 'string', example: '12 hours'), + new OA\Property( + property: 'requeue_until', + type: 'string', + format: 'date-time', + example: '2025-04-20T00:00:00+04:00' + ), + ], + type: 'object' + ), + new OA\Property( + property: 'message_options', + properties: [ + new OA\Property(property: 'from_field', type: 'string', example: 'info@example.com'), + new OA\Property(property: 'to_field', type: 'string', example: 'subscriber@example.com'), + new OA\Property(property: 'reply_to', type: 'string', example: 'reply@example.com'), + new OA\Property(property: 'embargo', type: 'string', example: '2025-04-17 09:00:00'), + new OA\Property(property: 'user_selection', type: 'string', example: 'all-active-users'), + ], + type: 'object' + ), + ], + type: 'object' + ) + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + 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: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function createMessage(Request $request, MessageNormalizer $normalizer): JsonResponse + { + $authUser = $this->requireAuthentication($request); + + /** @var CreateMessageRequest $createMessageRequest */ + $createMessageRequest = $this->validator->validate($request, CreateMessageRequest::class); + $data = $this->messageManager->createMessage($createMessageRequest, $authUser); + + return new JsonResponse($normalizer->normalize($data), Response::HTTP_CREATED); + } } diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index ef7f177..f8c6ac9 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -295,7 +295,12 @@ public function deleteList( response: 403, description: 'Failure', content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), ] )] public function createList(Request $request, SubscriberListNormalizer $normalizer): JsonResponse diff --git a/src/Entity/Request/CreateMessageRequest.php b/src/Entity/Request/CreateMessageRequest.php new file mode 100644 index 0000000..5d64b87 --- /dev/null +++ b/src/Entity/Request/CreateMessageRequest.php @@ -0,0 +1,35 @@ +', nullable: true), + new OA\Property( + property: 'from_field', + type: 'string', + example: ' My Name ', + nullable: true + ), new OA\Property(property: 'to_field', type: 'string', example: '', nullable: true), new OA\Property(property: 'reply_to', type: 'string', nullable: true), new OA\Property(property: 'embargo', type: 'string', example: '2023-01-01T12:00:00Z', nullable: true), new OA\Property(property: 'user_selection', type: 'string', nullable: true), ], - type: 'object'), + type: 'object' + ), ], type: 'object' )] diff --git a/src/Serializer/MessageNormalizer.php b/src/Serializer/MessageNormalizer.php index 1723a71..f290071 100644 --- a/src/Serializer/MessageNormalizer.php +++ b/src/Serializer/MessageNormalizer.php @@ -18,6 +18,12 @@ public function normalize($object, string $format = null, array $context = []): return []; } + $formatOptions = array_keys(array_filter([ + 'text' => $object->getFormat()->isAsText(), + 'html' => $object->getFormat()->isAsHtml(), + 'pdf' => $object->getFormat()->isAsPdf(), + ])); + return [ 'id' => $object->getId(), 'unique_id' => $object->getUuid(), @@ -37,11 +43,7 @@ public function normalize($object, string $format = null, array $context = []): 'message_format' => [ 'html_formated' => $object->getFormat()->isHtmlFormatted(), 'send_format' => $object->getFormat()->getSendFormat(), - 'as_text' => $object->getFormat()->isAsText(), - 'as_html' => $object->getFormat()->isAsHtml(), - 'as_pdf' => $object->getFormat()->isAsPdf(), - 'as_text_and_html' => $object->getFormat()->isAsTextAndHtml(), - 'as_text_and_pdf' => $object->getFormat()->isAsTextAndPdf(), + 'format_options' => $formatOptions, ], 'message_metadata' => [ 'status' => $object->getMetadata()->getStatus(), @@ -61,7 +63,7 @@ public function normalize($object, string $format = null, array $context = []): 'from_field' => $object->getOptions()->getFromField(), 'to_field' => $object->getOptions()->getToField(), 'reply_to' => $object->getOptions()->getReplyTo(), - 'embargo' => $object->getOptions()->getEmbargo(), + 'embargo' => $object->getOptions()->getEmbargo()?->format('Y-m-d\TH:i:sP'), 'user_selection' => $object->getOptions()->getUserSelection(), ], ]; diff --git a/src/Service/Manager/MessageManager.php b/src/Service/Manager/MessageManager.php new file mode 100644 index 0000000..ba03e16 --- /dev/null +++ b/src/Service/Manager/MessageManager.php @@ -0,0 +1,62 @@ +messageRepository = $messageRepository; + } + + public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $authUser): Message + { + $format = new Message\MessageFormat( + $createMessageRequest->format->htmlFormated, + $createMessageRequest->format->sendFormat, + $createMessageRequest->format->formatOptions, + ); + + $schedule = new Message\MessageSchedule( + $createMessageRequest->schedule->repeatInterval, + new DateTime($createMessageRequest->schedule->repeatUntil), + $createMessageRequest->schedule->requeueInterval, + new DateTime($createMessageRequest->schedule->requeueUntil), + ); + + $metadata = new Message\MessageMetadata($createMessageRequest->metadata->status); + + $content = new Message\MessageContent( + $createMessageRequest->content->subject, + $createMessageRequest->content->text, + $createMessageRequest->content->textMessage, + $createMessageRequest->content->footer, + ); + + $options = new Message\MessageOptions( + $createMessageRequest->options->fromField ?? '', + $createMessageRequest->options->toField ?? '', + $createMessageRequest->options->replyTo ?? '', + new DateTime($createMessageRequest->options->embargo), + $createMessageRequest->options->userSelection, + null, + null + ); + + $message = new Message($format, $schedule, $metadata, $content, $options, $authUser); + + $this->messageRepository->save($message); + + return $message; + } +} From 70b7c55d665433e8e2bc7d909c4a86ca594ec612 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 19 Apr 2025 15:31:18 +0400 Subject: [PATCH 33/62] ISSUE-345: get message by id endpoint --- src/Controller/CampaignController.php | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 271e1e2..3d9872f 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Controller; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Model\Messaging\Message; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateMessageRequest; @@ -12,6 +13,7 @@ use PhpList\RestBundle\Service\Manager\MessageManager; use PhpList\RestBundle\Service\Provider\MessageProvider; use PhpList\RestBundle\Validator\RequestValidator; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -92,6 +94,45 @@ public function getMessages(Request $request): JsonResponse return new JsonResponse($normalized, Response::HTTP_OK); } + #[Route('/{messageId}', name: 'get_campaign', methods: ['GET'])] + #[OA\Get( + path: '/campaigns/{messageId}', + description: 'Returns campaign/message by id.', + summary: 'Gets a campaign by id.', + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + 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 getMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] Message $message + ): JsonResponse { + $this->requireAuthentication($request); + + return new JsonResponse($this->normalizer->normalize($message), Response::HTTP_OK); + } + #[Route('', name: 'create_message', methods: ['POST'])] #[OA\Post( path: '/campaigns', From ab7b6d5b7899ad44214672d6632f917dbf878322 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 19 Apr 2025 19:25:46 +0400 Subject: [PATCH 34/62] ISSUE-345: template id validation --- config/services/validators.yml | 5 +++ src/Controller/CampaignController.php | 15 ++++++- src/Entity/Request/CreateMessageRequest.php | 4 ++ .../Request/Message/MessageOptionsRequest.php | 3 -- .../Message/MessageScheduleRequest.php | 3 ++ src/OpenApi/SwaggerSchemasEntity.php | 2 +- src/Serializer/MessageNormalizer.php | 10 +---- src/Service/Manager/MessageManager.php | 13 ++++-- src/Validator/TemplateExists.php | 22 ++++++++++ src/Validator/TemplateExistsValidator.php | 43 +++++++++++++++++++ 10 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/Validator/TemplateExists.php create mode 100644 src/Validator/TemplateExistsValidator.php diff --git a/config/services/validators.yml b/config/services/validators.yml index 5df52ea..8794db5 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -13,3 +13,8 @@ services: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Validator\TemplateExistsValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 3d9872f..89c8d3a 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -109,6 +109,13 @@ public function getMessages(Request $request): JsonResponse 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: [ @@ -143,6 +150,7 @@ public function getMessage( required: true, content: new OA\JsonContent( properties: [ + new OA\Property(property: 'template_id', type: 'integer', example: 1), new OA\Property( property: 'message_content', properties: [ @@ -182,6 +190,12 @@ enum: ['html', 'text', 'invite'], new OA\Property( property: 'message_schedule', properties: [ + new OA\Property( + property: 'embargo', + type: 'string', + format: 'date-time', + example: '2025-04-17 09:00:00' + ), new OA\Property(property: 'repeat_interval', type: 'string', example: '24 hours'), new OA\Property( property: 'repeat_until', @@ -205,7 +219,6 @@ enum: ['html', 'text', 'invite'], new OA\Property(property: 'from_field', type: 'string', example: 'info@example.com'), new OA\Property(property: 'to_field', type: 'string', example: 'subscriber@example.com'), new OA\Property(property: 'reply_to', type: 'string', example: 'reply@example.com'), - new OA\Property(property: 'embargo', type: 'string', example: '2025-04-17 09:00:00'), new OA\Property(property: 'user_selection', type: 'string', example: 'all-active-users'), ], type: 'object' diff --git a/src/Entity/Request/CreateMessageRequest.php b/src/Entity/Request/CreateMessageRequest.php index 5d64b87..80b4cef 100644 --- a/src/Entity/Request/CreateMessageRequest.php +++ b/src/Entity/Request/CreateMessageRequest.php @@ -10,6 +10,7 @@ use PhpList\RestBundle\Entity\Request\Message\MessageOptionsRequest; use PhpList\RestBundle\Entity\Request\Message\MessageScheduleRequest; use Symfony\Component\Validator\Constraints as Assert; +use PhpList\RestBundle\Validator as CustomAssert; class CreateMessageRequest implements RequestInterface { @@ -32,4 +33,7 @@ class CreateMessageRequest implements RequestInterface #[Assert\Valid] #[Assert\NotNull] public MessageOptionsRequest $options; + + #[CustomAssert\TemplateExists] + public ?int $templateId; } diff --git a/src/Entity/Request/Message/MessageOptionsRequest.php b/src/Entity/Request/Message/MessageOptionsRequest.php index 8689058..71aebcc 100644 --- a/src/Entity/Request/Message/MessageOptionsRequest.php +++ b/src/Entity/Request/Message/MessageOptionsRequest.php @@ -17,8 +17,5 @@ class MessageOptionsRequest #[Assert\Email] public ?string $replyTo = null; - #[Assert\NotBlank] - public string $embargo; - public ?string $userSelection = null; } diff --git a/src/Entity/Request/Message/MessageScheduleRequest.php b/src/Entity/Request/Message/MessageScheduleRequest.php index f6a00aa..f5cc63e 100644 --- a/src/Entity/Request/Message/MessageScheduleRequest.php +++ b/src/Entity/Request/Message/MessageScheduleRequest.php @@ -19,4 +19,7 @@ class MessageScheduleRequest #[Assert\DateTime] public string $requeueUntil; + + #[Assert\NotBlank] + public string $embargo; } diff --git a/src/OpenApi/SwaggerSchemasEntity.php b/src/OpenApi/SwaggerSchemasEntity.php index b8219a2..b535629 100644 --- a/src/OpenApi/SwaggerSchemasEntity.php +++ b/src/OpenApi/SwaggerSchemasEntity.php @@ -125,6 +125,7 @@ new OA\Property(property: 'repeat_until', type: 'string', format: 'date-time', nullable: true), new OA\Property(property: 'requeue_interval', type: 'string', nullable: true), new OA\Property(property: 'requeue_until', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'embargo', type: 'string', example: '2023-01-01T12:00:00Z', nullable: true), ], type: 'object' ), @@ -139,7 +140,6 @@ ), new OA\Property(property: 'to_field', type: 'string', example: '', nullable: true), new OA\Property(property: 'reply_to', type: 'string', nullable: true), - new OA\Property(property: 'embargo', type: 'string', example: '2023-01-01T12:00:00Z', nullable: true), new OA\Property(property: 'user_selection', type: 'string', nullable: true), ], type: 'object' diff --git a/src/Serializer/MessageNormalizer.php b/src/Serializer/MessageNormalizer.php index f290071..449683c 100644 --- a/src/Serializer/MessageNormalizer.php +++ b/src/Serializer/MessageNormalizer.php @@ -18,12 +18,6 @@ public function normalize($object, string $format = null, array $context = []): return []; } - $formatOptions = array_keys(array_filter([ - 'text' => $object->getFormat()->isAsText(), - 'html' => $object->getFormat()->isAsHtml(), - 'pdf' => $object->getFormat()->isAsPdf(), - ])); - return [ 'id' => $object->getId(), 'unique_id' => $object->getUuid(), @@ -43,7 +37,7 @@ public function normalize($object, string $format = null, array $context = []): 'message_format' => [ 'html_formated' => $object->getFormat()->isHtmlFormatted(), 'send_format' => $object->getFormat()->getSendFormat(), - 'format_options' => $formatOptions, + 'format_options' => $object->getFormat()->getFormatOptions() ], 'message_metadata' => [ 'status' => $object->getMetadata()->getStatus(), @@ -58,12 +52,12 @@ public function normalize($object, string $format = null, array $context = []): 'repeat_until' => $object->getSchedule()->getRepeatUntil()?->format('Y-m-d\TH:i:sP'), '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'), ], 'message_options' => [ 'from_field' => $object->getOptions()->getFromField(), 'to_field' => $object->getOptions()->getToField(), 'reply_to' => $object->getOptions()->getReplyTo(), - 'embargo' => $object->getOptions()->getEmbargo()?->format('Y-m-d\TH:i:sP'), 'user_selection' => $object->getOptions()->getUserSelection(), ], ]; diff --git a/src/Service/Manager/MessageManager.php b/src/Service/Manager/MessageManager.php index ba03e16..7286be7 100644 --- a/src/Service/Manager/MessageManager.php +++ b/src/Service/Manager/MessageManager.php @@ -8,15 +8,18 @@ use PhpList\Core\Domain\Model\Identity\Administrator; use PhpList\Core\Domain\Model\Messaging\Message; use PhpList\Core\Domain\Repository\Messaging\MessageRepository; +use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use PhpList\RestBundle\Entity\Request\CreateMessageRequest; class MessageManager { private MessageRepository $messageRepository; + private TemplateRepository $templateRepository; - public function __construct(MessageRepository $messageRepository) + public function __construct(MessageRepository $messageRepository, TemplateRepository $templateRepository) { $this->messageRepository = $messageRepository; + $this->templateRepository = $templateRepository; } public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $authUser): Message @@ -32,6 +35,7 @@ public function createMessage(CreateMessageRequest $createMessageRequest, Admini new DateTime($createMessageRequest->schedule->repeatUntil), $createMessageRequest->schedule->requeueInterval, new DateTime($createMessageRequest->schedule->requeueUntil), + new DateTime($createMessageRequest->schedule->embargo), ); $metadata = new Message\MessageMetadata($createMessageRequest->metadata->status); @@ -47,13 +51,16 @@ public function createMessage(CreateMessageRequest $createMessageRequest, Admini $createMessageRequest->options->fromField ?? '', $createMessageRequest->options->toField ?? '', $createMessageRequest->options->replyTo ?? '', - new DateTime($createMessageRequest->options->embargo), $createMessageRequest->options->userSelection, null, null ); - $message = new Message($format, $schedule, $metadata, $content, $options, $authUser); + if ($createMessageRequest->templateId > 0) { + $template = $this->templateRepository->find($createMessageRequest->templateId); + } + + $message = new Message($format, $schedule, $metadata, $content, $options, $authUser, $template ?? null); $this->messageRepository->save($message); diff --git a/src/Validator/TemplateExists.php b/src/Validator/TemplateExists.php new file mode 100644 index 0000000..7663ceb --- /dev/null +++ b/src/Validator/TemplateExists.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Validator/TemplateExistsValidator.php b/src/Validator/TemplateExistsValidator.php new file mode 100644 index 0000000..319852c --- /dev/null +++ b/src/Validator/TemplateExistsValidator.php @@ -0,0 +1,43 @@ +templateRepository = $templateRepository; + } + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof TemplateExists) { + throw new UnexpectedTypeException($constraint, TemplateExists::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_int($value)) { + throw new UnexpectedValueException($value, 'integer'); + } + + $existingUser = $this->templateRepository->find($value); + + if (!$existingUser) { + throw new ConflictHttpException('Template with that id does not exists.'); + } + } +} From a90900bdd31a2698e370f8f0a35987c5c58b86d6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 19 Apr 2025 20:25:10 +0400 Subject: [PATCH 35/62] ISSUE-345: fixtures in folders --- .../Integration/Controller/AbstractTestController.php | 4 ++-- .../Fixtures/{ => Identity}/Administrator.csv | 0 .../Fixtures/{ => Identity}/AdministratorFixture.php | 2 +- .../Fixtures/{ => Identity}/AdministratorToken.csv | 0 .../{ => Identity}/AdministratorTokenFixture.php | 2 +- .../Fixtures/{ => Messaging}/SubscriberList.csv | 0 .../Fixtures/{ => Messaging}/SubscriberListFixture.php | 2 +- .../Fixtures/{ => Subscription}/Subscriber.csv | 0 .../Fixtures/{ => Subscription}/SubscriberFixture.php | 2 +- .../Fixtures/{ => Subscription}/Subscription.csv | 0 .../{ => Subscription}/SubscriptionFixture.php | 4 ++-- tests/Integration/Controller/ListControllerTest.php | 10 +++++----- tests/Integration/Controller/SessionControllerTest.php | 4 ++-- .../Controller/SubscriberControllerTest.php | 2 +- 14 files changed, 16 insertions(+), 16 deletions(-) rename tests/Integration/Controller/Fixtures/{ => Identity}/Administrator.csv (100%) rename tests/Integration/Controller/Fixtures/{ => Identity}/AdministratorFixture.php (99%) rename tests/Integration/Controller/Fixtures/{ => Identity}/AdministratorToken.csv (100%) rename tests/Integration/Controller/Fixtures/{ => Identity}/AdministratorTokenFixture.php (99%) rename tests/Integration/Controller/Fixtures/{ => Messaging}/SubscriberList.csv (100%) rename tests/Integration/Controller/Fixtures/{ => Messaging}/SubscriberListFixture.php (99%) rename tests/Integration/Controller/Fixtures/{ => Subscription}/Subscriber.csv (100%) rename tests/Integration/Controller/Fixtures/{ => Subscription}/SubscriberFixture.php (99%) rename tests/Integration/Controller/Fixtures/{ => Subscription}/Subscription.csv (100%) rename tests/Integration/Controller/Fixtures/{ => Subscription}/SubscriptionFixture.php (99%) diff --git a/tests/Integration/Controller/AbstractTestController.php b/tests/Integration/Controller/AbstractTestController.php index dfd4181..57d2a47 100644 --- a/tests/Integration/Controller/AbstractTestController.php +++ b/tests/Integration/Controller/AbstractTestController.php @@ -6,8 +6,8 @@ use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorFixture; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorTokenFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity\AdministratorFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity\AdministratorTokenFixture; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\HttpFoundation\Response; diff --git a/tests/Integration/Controller/Fixtures/Administrator.csv b/tests/Integration/Controller/Fixtures/Identity/Administrator.csv similarity index 100% rename from tests/Integration/Controller/Fixtures/Administrator.csv rename to tests/Integration/Controller/Fixtures/Identity/Administrator.csv diff --git a/tests/Integration/Controller/Fixtures/AdministratorFixture.php b/tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php similarity index 99% rename from tests/Integration/Controller/Fixtures/AdministratorFixture.php rename to tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php index 09f30d6..ede810b 100644 --- a/tests/Integration/Controller/Fixtures/AdministratorFixture.php +++ b/tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures; +namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; diff --git a/tests/Integration/Controller/Fixtures/AdministratorToken.csv b/tests/Integration/Controller/Fixtures/Identity/AdministratorToken.csv similarity index 100% rename from tests/Integration/Controller/Fixtures/AdministratorToken.csv rename to tests/Integration/Controller/Fixtures/Identity/AdministratorToken.csv diff --git a/tests/Integration/Controller/Fixtures/AdministratorTokenFixture.php b/tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php similarity index 99% rename from tests/Integration/Controller/Fixtures/AdministratorTokenFixture.php rename to tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php index d3d022b..a4f4aec 100644 --- a/tests/Integration/Controller/Fixtures/AdministratorTokenFixture.php +++ b/tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures; +namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; diff --git a/tests/Integration/Controller/Fixtures/SubscriberList.csv b/tests/Integration/Controller/Fixtures/Messaging/SubscriberList.csv similarity index 100% rename from tests/Integration/Controller/Fixtures/SubscriberList.csv rename to tests/Integration/Controller/Fixtures/Messaging/SubscriberList.csv diff --git a/tests/Integration/Controller/Fixtures/SubscriberListFixture.php b/tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php similarity index 99% rename from tests/Integration/Controller/Fixtures/SubscriberListFixture.php rename to tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php index 93287ff..b64d6b9 100644 --- a/tests/Integration/Controller/Fixtures/SubscriberListFixture.php +++ b/tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures; +namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Messaging; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; diff --git a/tests/Integration/Controller/Fixtures/Subscriber.csv b/tests/Integration/Controller/Fixtures/Subscription/Subscriber.csv similarity index 100% rename from tests/Integration/Controller/Fixtures/Subscriber.csv rename to tests/Integration/Controller/Fixtures/Subscription/Subscriber.csv diff --git a/tests/Integration/Controller/Fixtures/SubscriberFixture.php b/tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php similarity index 99% rename from tests/Integration/Controller/Fixtures/SubscriberFixture.php rename to tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php index 9a3b600..3281cd1 100644 --- a/tests/Integration/Controller/Fixtures/SubscriberFixture.php +++ b/tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures; +namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Subscription; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; diff --git a/tests/Integration/Controller/Fixtures/Subscription.csv b/tests/Integration/Controller/Fixtures/Subscription/Subscription.csv similarity index 100% rename from tests/Integration/Controller/Fixtures/Subscription.csv rename to tests/Integration/Controller/Fixtures/Subscription/Subscription.csv diff --git a/tests/Integration/Controller/Fixtures/SubscriptionFixture.php b/tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php similarity index 99% rename from tests/Integration/Controller/Fixtures/SubscriptionFixture.php rename to tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php index 66b4028..f6eebf4 100644 --- a/tests/Integration/Controller/Fixtures/SubscriptionFixture.php +++ b/tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures; +namespace PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Subscription; use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; -use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Model\Subscription\Subscriber; +use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Model\Subscription\Subscription; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index 8c3825b..84bfa31 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -6,11 +6,11 @@ use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\RestBundle\Controller\ListController; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorFixture; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorTokenFixture; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\SubscriberFixture; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\SubscriberListFixture; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\SubscriptionFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity\AdministratorFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity\AdministratorTokenFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Messaging\SubscriberListFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Subscription\SubscriberFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Subscription\SubscriptionFixture; /** * Testcase. diff --git a/tests/Integration/Controller/SessionControllerTest.php b/tests/Integration/Controller/SessionControllerTest.php index 6dcd4a1..17d3286 100644 --- a/tests/Integration/Controller/SessionControllerTest.php +++ b/tests/Integration/Controller/SessionControllerTest.php @@ -8,8 +8,8 @@ use PhpList\Core\Domain\Model\Identity\AdministratorToken; use PhpList\Core\Domain\Repository\Identity\AdministratorTokenRepository; use PhpList\RestBundle\Controller\SessionController; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorFixture; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\AdministratorTokenFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity\AdministratorFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Identity\AdministratorTokenFixture; /** * Testcase. diff --git a/tests/Integration/Controller/SubscriberControllerTest.php b/tests/Integration/Controller/SubscriberControllerTest.php index 346c6e0..80de32c 100644 --- a/tests/Integration/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Controller/SubscriberControllerTest.php @@ -7,7 +7,7 @@ use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\RestBundle\Controller\SubscriberController; -use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\SubscriberFixture; +use PhpList\RestBundle\Tests\Integration\Controller\Fixtures\Subscription\SubscriberFixture; /** * Testcase. From a81717bfa3e6b0d31702cec7d97566e50786f701 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 19 Apr 2025 20:25:24 +0400 Subject: [PATCH 36/62] ISSUE-345: campaign controller test --- .../Controller/CampaignControllerTest.php | 81 +++++++++++++ .../Controller/Fixtures/Messaging/Message.csv | 21 ++++ .../Fixtures/Messaging/MessageFixture.php | 109 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 tests/Integration/Controller/CampaignControllerTest.php create mode 100644 tests/Integration/Controller/Fixtures/Messaging/Message.csv create mode 100644 tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php diff --git a/tests/Integration/Controller/CampaignControllerTest.php b/tests/Integration/Controller/CampaignControllerTest.php new file mode 100644 index 0000000..605f97c --- /dev/null +++ b/tests/Integration/Controller/CampaignControllerTest.php @@ -0,0 +1,81 @@ +get(CampaignController::class)); + } + + public function testGetCampaignsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/campaigns'); + $this->assertHttpForbidden(); + } + + public function testGetCampaignsWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/campaigns', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testGetCampaignsWithValidSessionReturnsOkay(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns'); + $this->assertHttpOkay(); + } + + public function testGetCampaignsReturnsCampaignData(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('id', $response[0]); + self::assertArrayHasKey('message_content', $response[0]); + } + + public function testGetSingleCampaignWithValidSessionReturnsData(): void + { + $this->loadFixtures([MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns/1'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame(1, $response['id']); + } + + public function testGetSingleCampaignWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([MessageFixture::class]); + self::getClient()->request('GET', '/api/v2/campaigns/1'); + $this->assertHttpForbidden(); + } + + public function testGetCampaignWithInvalidIdReturnsNotFound(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns/999'); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Controller/Fixtures/Messaging/Message.csv b/tests/Integration/Controller/Fixtures/Messaging/Message.csv new file mode 100644 index 0000000..c2e52a7 --- /dev/null +++ b/tests/Integration/Controller/Fixtures/Messaging/Message.csv @@ -0,0 +1,21 @@ +id,uuid,subject,fromfield,tofield,replyto,message,textmessage,footer,entered,modified,embargo,repeatinterval,repeatuntil,requeueinterval,requeueuntil,status,userselection,sent,htmlformatted,sendformat,template,processed,astext,ashtml,astextandhtml,aspdf,astextandpdf,viewed,bouncecount,sendstart,rsstemplate,owner +1,2df6b147-8470-45ed-8e4e-86aa01af400d,Do you want to continue receiving our messages?, My Name ,"","","

Hi [FIRST NAME%%there], remember us? You first signed up for our email newsletter on [ENTERED] – please click here to confirm you're happy to continue receiving our messages:

+ +

Continue receiving messages  (If you do not confirm using this link, then you won't hear from us again)

+ +

While you're at it, you can also update your preferences, including your email address or other details, by clicking here:

+ +

Update preferences

+ +

By confirming your membership and keeping your details up to date, you're helping us to manage and protect your data in accordance with best practices.

+ +

Thank you!

","","-- + +
+

This message was sent to [EMAIL] by [FROMEMAIL].

+

To forward this message, please do not use the forward button of your email application, because this message was made specifically for you only. Instead use the forward page in our newsletter system.
+ To change your details and to choose which lists to be subscribed to, visit your personal preferences page.
+ Or you can opt-out completely from all future mailings.

+
+ + ",2024-11-10 16:57:46,2024-11-14 08:32:15,2024-11-14 08:32:00,0,2024-11-14 08:32:00,0,2024-11-14 08:32:00,sent,,2024-11-14 08:32:15,1,invite,0,0,0,0,0,0,0,0,0,2024-11-14 08:32:15,,1 diff --git a/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php b/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php new file mode 100644 index 0000000..800fa91 --- /dev/null +++ b/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php @@ -0,0 +1,109 @@ +getRepository(Administrator::class); + $templateRepository = $manager->getRepository(Template::class); + + do { + $data = fgetcsv($handle); + if ($data === false) { + break; + } + $row = array_combine($headers, $data); + $admin = $adminRepository->find($row['owner']); + $template = $templateRepository->find($row['template']); + + $format = new MessageFormat( + (bool)$row['htmlformatted'], + $row['sendformat'], + array_keys(array_filter([ + MessageFormat::FORMAT_TEXT => $row['astext'], + MessageFormat::FORMAT_HTML => $row['ashtml'], + MessageFormat::FORMAT_PDF => $row['aspdf'], + ])) + ); + + $schedule = new MessageSchedule( + (int)$row['repeatinterval'], + new DateTime($row['repeatuntil']), + (int)$row['requeueinterval'], + new DateTime($row['requeueuntil']), + new DateTime($row['embargo']), + ); + $metadata = new MessageMetadata( + $row['status'], + (int)$row['bouncecount'], + new DateTime($row['entered']), + new DateTime($row['sent']), + ); + $metadata->setProcessed((bool) $row['processed']); + $metadata->setViews((int)$row['viewed']); + $content = new MessageContent( + $row['subject'], + $row['message'], + $row['textmessage'], + $row['footer'] + ); + $options = new MessageOptions( + $row['fromfield'], + $row['tofield'], + $row['replyto'], + $row['userselection'], + new DateTime($row['sendstart']), + $row['rsstemplate'], + ); + + $message = new Message( + $format, + $schedule, + $metadata, + $content, + $options, + $admin, + $template, + ); + $this->setSubjectId($message, (int)$row['id']); + $this->setSubjectProperty($message, 'uuid', $row['uuid']); + + $manager->persist($message); + $this->setSubjectProperty($message, 'modificationDate', new DateTime($row['modified'])); + } while (true); + + fclose($handle); + } +} From 1173da81c80efd58854ab989a20a7abeb93db4df Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 19 Apr 2025 20:42:20 +0400 Subject: [PATCH 37/62] ISSUE-345: subscription controller test --- src/Controller/ListController.php | 7 +- .../Request/CreateSubscriberListRequest.php | 3 - src/Service/Manager/SubscriberListManager.php | 13 +- src/Validator/EmailExistsValidator.php | 4 +- .../Controller/ListControllerTest.php | 61 +++----- .../Controller/SubscriptionControllerTest.php | 134 ++++++++++++++++++ 6 files changed, 168 insertions(+), 54 deletions(-) create mode 100644 tests/Integration/Controller/SubscriptionControllerTest.php diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index f8c6ac9..67d352a 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -257,7 +257,6 @@ public function deleteList( new OA\Property(property: 'description', type: 'string', example: 'News (and some fun stuff)'), new OA\Property(property: 'list_position', type: 'number', example: 12), new OA\Property(property: 'public', type: 'boolean', example: true), - new OA\Property(property: 'owner', type: 'number', example: 12), ] ) ), @@ -305,12 +304,12 @@ public function deleteList( )] public function createList(Request $request, SubscriberListNormalizer $normalizer): JsonResponse { - $this->requireAuthentication($request); + $authUser = $this->requireAuthentication($request); /** @var CreateSubscriberListRequest $subscriberListRequest */ $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); - $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest); + $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest, $authUser); - return new JsonResponse($normalizer->normalize($data), Response::HTTP_CREATED, [], true); + return new JsonResponse($normalizer->normalize($data), Response::HTTP_CREATED); } } diff --git a/src/Entity/Request/CreateSubscriberListRequest.php b/src/Entity/Request/CreateSubscriberListRequest.php index 5fab655..30988d6 100644 --- a/src/Entity/Request/CreateSubscriberListRequest.php +++ b/src/Entity/Request/CreateSubscriberListRequest.php @@ -18,9 +18,6 @@ class CreateSubscriberListRequest implements RequestInterface #[Assert\NotBlank] public int $listPosition; - #[Assert\NotBlank] - public int $owner; - #[Assert\NotBlank] #[Assert\NotNull] public string $description; diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php index 809aa99..c8d72ad 100644 --- a/src/Service/Manager/SubscriberListManager.php +++ b/src/Service/Manager/SubscriberListManager.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Service\Manager; +use PhpList\Core\Domain\Model\Identity\Administrator; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; @@ -12,16 +13,18 @@ class SubscriberListManager { private SubscriberListRepository $subscriberListRepository; - public function __construct( - SubscriberListRepository $subscriberListRepository, - ) { + public function __construct(SubscriberListRepository $subscriberListRepository) + { $this->subscriberListRepository = $subscriberListRepository; } - public function createSubscriberList(CreateSubscriberListRequest $subscriberListRequest): SubscriberList - { + public function createSubscriberList( + CreateSubscriberListRequest $subscriberListRequest, + Administrator $authUser + ): SubscriberList { $subscriberList = (new SubscriberList()) ->setName($subscriberListRequest->name) + ->setOwner($authUser) ->setDescription($subscriberListRequest->description) ->setListPosition($subscriberListRequest->listPosition) ->setPublic($subscriberListRequest->public); diff --git a/src/Validator/EmailExistsValidator.php b/src/Validator/EmailExistsValidator.php index cd2e627..d971dc6 100644 --- a/src/Validator/EmailExistsValidator.php +++ b/src/Validator/EmailExistsValidator.php @@ -5,7 +5,7 @@ namespace PhpList\RestBundle\Validator; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; @@ -37,7 +37,7 @@ public function validate($value, Constraint $constraint): void $existingUser = $this->subscriberRepository->findOneBy(['email' => $value]); if (!$existingUser) { - throw new ConflictHttpException('Subscriber with email does not exists.'); + throw new NotFoundHttpException('Subscriber with email does not exists.'); } } } diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index 84bfa31..2a9df5f 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -290,60 +290,41 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr ); } - public function testGetListSubscribersCountForExistingListWithoutSessionKeyReturnsForbiddenStatus() + public function testCreateListWithValidPayloadReturns201(): void { - $this->loadFixtures([SubscriberListFixture::class]); - - self::getClient()->request('get', '/api/v2/lists/1/subscribers/count'); - - $this->assertHttpForbidden(); - } + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); - public function testGetListSubscribersCountForExistingListWithExpiredSessionKeyReturnsForbiddenStatus() - { - $this->loadFixtures([ - SubscriberListFixture::class, - AdministratorFixture::class, - AdministratorTokenFixture::class, + $payload = json_encode([ + 'name' => 'New List', + 'description' => 'This is a new subscriber list.', + 'listPosition' => 3, + 'public' => true, ]); - self::getClient()->request( - 'get', - '/api/v2/lists/1/subscribers/count', - [], - [], - ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'cfdf64eecbbf336628b0f3071adba764'] - ); - - $this->assertHttpForbidden(); - } - - public function testGetListSubscribersCountWithCurrentSessionKeyForExistingListReturnsOkayStatus() - { - $this->loadFixtures([SubscriberListFixture::class]); + $this->authenticatedJsonRequest('POST', '/api/v2/lists', [], [], [], $payload); - $this->authenticatedJsonRequest('get', '/api/v2/lists/1/subscribers/count'); + $this->assertHttpCreated(); + $response = $this->getDecodedJsonResponseContent(); - $this->assertHttpOkay(); + self::assertSame('New List', $response['name']); } - public function testGetSubscribersCountForEmptyListWithValidSession() + public function testCreateListWithMissingNameReturnsValidationError(): void { - $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); - $this->authenticatedJsonRequest('get', '/api/v2/lists/3/subscribers/count'); - $responseData = $this->getDecodedJsonResponseContent(); + $payload = [ + 'description' => 'Missing name field' + ]; - self::assertSame(0, $responseData['subscribers_count']); + $this->authenticatedJsonRequest('POST', '/api/v2/lists', [], [], [], json_encode($payload)); + $this->assertHttpUnprocessableEntity(); } - public function testGetSubscribersCountForListWithValidSession() + public function testCreateListWithoutSessionKeyReturnsForbidden(): void { - $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); - - $this->authenticatedJsonRequest('get', '/api/v2/lists/2/subscribers/count'); - $responseData = $this->getDecodedJsonResponseContent(); + self::getClient()->request('POST', '/api/v2/lists', [], [], [], json_encode([ 'name' => 'UnauthorizedList'])); - self::assertSame(2, $responseData['subscribers_count']); + $this->assertHttpForbidden(); } } diff --git a/tests/Integration/Controller/SubscriptionControllerTest.php b/tests/Integration/Controller/SubscriptionControllerTest.php new file mode 100644 index 0000000..4ce2c3b --- /dev/null +++ b/tests/Integration/Controller/SubscriptionControllerTest.php @@ -0,0 +1,134 @@ +get(SubscriptionController::class) + ); + } + + public function testGetSubscribersWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([SubscriberListFixture::class]); + self::getClient()->request('GET', '/api/v2/lists/1/subscribers'); + $this->assertHttpForbidden(); + } + + public function testGetSubscribersWithSessionReturnsList(): void + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + $this->authenticatedJsonRequest('GET', '/api/v2/lists/2/subscribers'); + $this->assertHttpOkay(); + } + + public function testGetSubscribersCountWithSessionReturnsCorrectCount(): void + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + $this->authenticatedJsonRequest('GET', '/api/v2/lists/2/subscribers/count'); + $data = $this->getDecodedJsonResponseContent(); + self::assertSame(2, $data['subscribers_count']); + } + + public function testCreateSubscriptionWithValidEmailsReturns201(): void + { + $this->loadFixtures([ + SubscriberListFixture::class, + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscriberFixture::class, + ]); + + $payload = json_encode(['emails' => ['oliver@example.com']]); + + $this->authenticatedJsonRequest('POST', '/api/v2/lists/1/subscribers', [], [], [], $payload); + $this->assertHttpCreated(); + } + + public function testDeleteSubscriptionReturnsNoContent(): void + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/lists/2/subscribers?emails[]=oliver@example.com'); + $this->assertHttpNoContent(); + } + + public function testDeleteSubscriptionForUnknownEmailReturnsValidationError(): void + { + $this->loadFixtures([SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/lists/1/subscribers?emails[]=unknown@example.com'); + $this->assertHttpNotFound(); + } + + + public function testGetListSubscribersCountForExistingListWithoutSessionKeyReturnsForbiddenStatus() + { + $this->loadFixtures([SubscriberListFixture::class]); + + self::getClient()->request('get', '/api/v2/lists/1/subscribers/count'); + + $this->assertHttpForbidden(); + } + + public function testGetListSubscribersCountForExistingListWithExpiredSessionKeyReturnsForbiddenStatus() + { + $this->loadFixtures([ + SubscriberListFixture::class, + AdministratorFixture::class, + AdministratorTokenFixture::class, + ]); + + self::getClient()->request( + 'get', + '/api/v2/lists/1/subscribers/count', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'cfdf64eecbbf336628b0f3071adba764'] + ); + + $this->assertHttpForbidden(); + } + + public function testGetListSubscribersCountWithCurrentSessionKeyForExistingListReturnsOkayStatus() + { + $this->loadFixtures([SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('get', '/api/v2/lists/1/subscribers/count'); + + $this->assertHttpOkay(); + } + + public function testGetSubscribersCountForEmptyListWithValidSession() + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + + $this->authenticatedJsonRequest('get', '/api/v2/lists/3/subscribers/count'); + $responseData = $this->getDecodedJsonResponseContent(); + + self::assertSame(0, $responseData['subscribers_count']); + } + + public function testGetSubscribersCountForListWithValidSession() + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + + $this->authenticatedJsonRequest('get', '/api/v2/lists/2/subscribers/count'); + $responseData = $this->getDecodedJsonResponseContent(); + + self::assertSame(2, $responseData['subscribers_count']); + } +} From e2fb0d46117424128178446eb295b5802503aa41 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 20 Apr 2025 12:30:11 +0400 Subject: [PATCH 38/62] ISSUE-345: add unit tests --- .../Controller/SessionControllerTest.php | 35 ++++++ .../AdministratorTokenNormalizerTest.php | 48 ++++++++ .../Unit/Serializer/MessageNormalizerTest.php | 90 +++++++++++++++ .../SubscriberListNormalizerTest.php | 55 +++++++++ .../Serializer/SubscriberNormalizerTest.php | 84 ++++++++++++++ .../Serializer/SubscriptionNormalizerTest.php | 67 +++++++++++ .../Service/Manager/MessageManagerTest.php | 75 +++++++++++++ .../Service/Manager/SessionManagerTest.php | 54 +++++++++ .../Manager/SubscriberListManagerTest.php | 75 +++++++++++++ .../Service/Manager/SubscriberManagerTest.php | 2 +- .../Manager/SubscriptionManagerTest.php | 106 ++++++++++++++++++ .../Service/Provider/MessageProviderTest.php | 39 +++++++ .../Validator/EmailExistsValidatorTest.php | 85 ++++++++++++++ .../Validator/RequestValidatorTest.php | 2 +- .../Validator/TemplateExistsValidatorTest.php | 85 ++++++++++++++ .../Validator/UniqueEmailValidatorTest.php | 4 +- 16 files changed, 902 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Serializer/AdministratorTokenNormalizerTest.php create mode 100644 tests/Unit/Serializer/MessageNormalizerTest.php create mode 100644 tests/Unit/Serializer/SubscriberListNormalizerTest.php create mode 100644 tests/Unit/Serializer/SubscriberNormalizerTest.php create mode 100644 tests/Unit/Serializer/SubscriptionNormalizerTest.php create mode 100644 tests/Unit/Service/Manager/MessageManagerTest.php create mode 100644 tests/Unit/Service/Manager/SessionManagerTest.php create mode 100644 tests/Unit/Service/Manager/SubscriberListManagerTest.php rename tests/{Integration => Unit}/Service/Manager/SubscriberManagerTest.php (96%) create mode 100644 tests/Unit/Service/Manager/SubscriptionManagerTest.php create mode 100644 tests/Unit/Service/Provider/MessageProviderTest.php create mode 100644 tests/Unit/Validator/EmailExistsValidatorTest.php rename tests/{Integration => Unit}/Validator/RequestValidatorTest.php (98%) create mode 100644 tests/Unit/Validator/TemplateExistsValidatorTest.php rename tests/{Integration => Unit}/Validator/UniqueEmailValidatorTest.php (98%) diff --git a/tests/Integration/Controller/SessionControllerTest.php b/tests/Integration/Controller/SessionControllerTest.php index 17d3286..eb7c8a7 100644 --- a/tests/Integration/Controller/SessionControllerTest.php +++ b/tests/Integration/Controller/SessionControllerTest.php @@ -193,4 +193,39 @@ public function testDeleteSessionWithCurrentSessionAndOwnSessionKeyKeepsSession( self::assertNotNull($this->administratorTokenRepository->find(3)); } + + public function testPostSessionWithExtraFieldsIsIgnored(): void + { + $this->loadFixtures([AdministratorFixture::class]); + + $jsonData = json_encode([ + 'loginName' => 'john.doe', + 'password' => 'Bazinga!', + 'extraField' => 'ignore_me' + ]); + + $this->jsonRequest('post', '/api/v2/sessions', [], [], [], $jsonData); + + $this->assertHttpCreated(); + $response = $this->getDecodedJsonResponseContent(); + self::assertArrayNotHasKey('extraField', $response); + } + + public function testDeleteSessionWithInvalidFormatIdReturns404(): void + { + $this->authenticatedJsonRequest('DELETE', '/api/v2/sessions/not-an-id'); + $this->assertHttpNotFound(); + } + + public function testPostSessionWithWrongHttpMethodReturns405(): void + { + self::getClient()->request('PUT', '/api/v2/sessions'); + $this->assertHttpMethodNotAllowed(); + } + + public function testDeleteSessionWithNoSuchSessionReturns404(): void + { + $this->authenticatedJsonRequest('DELETE', '/api/v2/sessions/999999'); + $this->assertHttpNotFound(); + } } diff --git a/tests/Unit/Serializer/AdministratorTokenNormalizerTest.php b/tests/Unit/Serializer/AdministratorTokenNormalizerTest.php new file mode 100644 index 0000000..5e1da82 --- /dev/null +++ b/tests/Unit/Serializer/AdministratorTokenNormalizerTest.php @@ -0,0 +1,48 @@ +createMock(AdministratorToken::class); + + $this->assertTrue($normalizer->supportsNormalization($token)); + $this->assertFalse($normalizer->supportsNormalization(AdministratorToken::class)); + } + + public function testNormalize(): void + { + $expiry = new DateTime('2025-01-01T12:00:00+00:00'); + + $token = $this->createMock(AdministratorToken::class); + $token->method('getId')->willReturn(42); + $token->method('getKey')->willReturn('abcdef123456'); + $token->method('getExpiry')->willReturn($expiry); + + $normalizer = new AdministratorTokenNormalizer(); + + $expected = [ + 'id' => 42, + 'key' => 'abcdef123456', + 'expiry_date' => '2025-01-01T12:00:00+00:00' + ]; + + $this->assertSame($expected, $normalizer->normalize($token)); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $normalizer = new AdministratorTokenNormalizer(); + $this->assertSame([], $normalizer->normalize(AdministratorToken::class)); + } +} diff --git a/tests/Unit/Serializer/MessageNormalizerTest.php b/tests/Unit/Serializer/MessageNormalizerTest.php new file mode 100644 index 0000000..85ec5b9 --- /dev/null +++ b/tests/Unit/Serializer/MessageNormalizerTest.php @@ -0,0 +1,90 @@ +createMock(Message::class); + $this->assertTrue($normalizer->supportsNormalization($message)); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $template = $this->createConfiguredMock(Template::class, [ + 'getId' => 5, + 'getTitle' => 'Test Template', + 'getTemplate' => 'Hello', + 'getTemplateText' => 'Hello', + 'getListOrder' => 1, + ]); + + $content = new MessageContent('Subject', 'Text', 'TextMsg', 'Footer'); + $format = new MessageFormat(true, 'html'); + $format->setFormatOptions(['text', 'html']); + + $entered = new DateTime('2025-01-01T10:00:00+00:00'); + $sent = new DateTime('2025-01-02T10:00:00+00:00'); + + $metadata = new MessageMetadata('draft'); + $metadata->setProcessed(true); + $metadata->setViews(10); + $metadata->setBounceCount(3); + $metadata->setEntered($entered); + $metadata->setSent($sent); + + $schedule = new MessageSchedule( + 24, + new DateTime('2025-01-10T00:00:00+00:00'), + 12, + new DateTime('2025-01-05T00:00:00+00:00'), + new DateTime('2025-01-01T00:00:00+00:00') + ); + + $options = new MessageOptions('from@example.com', 'to@example.com', 'reply@example.com', 'group'); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(1); + $message->method('getUuid')->willReturn('uuid-123'); + $message->method('getTemplate')->willReturn($template); + $message->method('getContent')->willReturn($content); + $message->method('getFormat')->willReturn($format); + $message->method('getMetadata')->willReturn($metadata); + $message->method('getSchedule')->willReturn($schedule); + $message->method('getOptions')->willReturn($options); + + $normalizer = new MessageNormalizer(); + $result = $normalizer->normalize($message); + + $this->assertSame(1, $result['id']); + $this->assertSame('uuid-123', $result['unique_id']); + $this->assertSame('Test Template', $result['template']['title']); + $this->assertSame('Subject', $result['message_content']['subject']); + $this->assertSame(['text', 'html'], $result['message_format']['format_options']); + $this->assertSame('draft', $result['message_metadata']['status']); + $this->assertSame('from@example.com', $result['message_options']['from_field']); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $normalizer = new MessageNormalizer(); + $this->assertSame([], $normalizer->normalize(new \stdClass())); + } +} diff --git a/tests/Unit/Serializer/SubscriberListNormalizerTest.php b/tests/Unit/Serializer/SubscriberListNormalizerTest.php new file mode 100644 index 0000000..0909833 --- /dev/null +++ b/tests/Unit/Serializer/SubscriberListNormalizerTest.php @@ -0,0 +1,55 @@ +createMock(SubscriberList::class); + $this->assertTrue($normalizer->supportsNormalization($subscriberList)); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalize(): void + { + $mock = $this->createMock(SubscriberList::class); + $mock->method('getId')->willReturn(101); + $mock->method('getName')->willReturn('Tech News'); + $mock->method('getCreationDate')->willReturn(new DateTime('2025-04-01T10:00:00+00:00')); + $mock->method('getDescription')->willReturn('All tech updates'); + $mock->method('getListPosition')->willReturn(2); + $mock->method('getSubjectPrefix')->willReturn('tech'); + $mock->method('isPublic')->willReturn(true); + $mock->method('getCategory')->willReturn('technology'); + + $normalizer = new SubscriberListNormalizer(); + $result = $normalizer->normalize($mock); + + $this->assertSame([ + 'id' => 101, + 'name' => 'Tech News', + 'creation_date' => '2025-04-01T10:00:00+00:00', + 'description' => 'All tech updates', + 'list_position' => 2, + 'subject_prefix' => 'tech', + 'public' => true, + 'category' => 'technology', + ], $result); + } + + public function testNormalizeWithInvalidObject(): void + { + $normalizer = new SubscriberListNormalizer(); + $this->assertSame([], $normalizer->normalize(new \stdClass())); + } +} diff --git a/tests/Unit/Serializer/SubscriberNormalizerTest.php b/tests/Unit/Serializer/SubscriberNormalizerTest.php new file mode 100644 index 0000000..1d35196 --- /dev/null +++ b/tests/Unit/Serializer/SubscriberNormalizerTest.php @@ -0,0 +1,84 @@ +createMock(Subscriber::class); + + $this->assertTrue($normalizer->supportsNormalization($subscriber)); + $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testNormalize(): void + { + $subscriberList = $this->createMock(SubscriberList::class); + $subscriberList->method('getId')->willReturn(1); + $subscriberList->method('getName')->willReturn('News'); + $subscriberList->method('getDescription')->willReturn('Latest news'); + $subscriberList->method('getCreationDate')->willReturn(new DateTime('2025-01-01T00:00:00+00:00')); + $subscriberList->method('isPublic')->willReturn(true); + + $subscription = $this->createMock(Subscription::class); + $subscription->method('getSubscriberList')->willReturn($subscriberList); + $subscription->method('getCreationDate')->willReturn(new DateTime('2025-01-10T00:00:00+00:00')); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn(101); + $subscriber->method('getEmail')->willReturn('test@example.com'); + $subscriber->method('getCreationDate')->willReturn(new DateTime('2024-12-31T12:00:00+00:00')); + $subscriber->method('isConfirmed')->willReturn(true); + $subscriber->method('isBlacklisted')->willReturn(false); + $subscriber->method('getBounceCount')->willReturn(0); + $subscriber->method('getUniqueId')->willReturn('abc123'); + $subscriber->method('hasHtmlEmail')->willReturn(true); + $subscriber->method('isDisabled')->willReturn(false); + $subscriber->method('getSubscriptions')->willReturn(new ArrayCollection([$subscription])); + + $normalizer = new SubscriberNormalizer(); + + $expected = [ + 'id' => 101, + 'email' => 'test@example.com', + 'creation_date' => '2024-12-31T12:00:00+00:00', + 'confirmed' => true, + 'blacklisted' => false, + 'bounce_count' => 0, + 'unique_id' => 'abc123', + 'html_email' => true, + 'disabled' => false, + 'subscribed_lists' => [ + [ + 'id' => 1, + 'name' => 'News', + 'description' => 'Latest news', + 'creation_date' => '2025-01-01T00:00:00+00:00', + 'public' => true, + 'subscription_date' => '2025-01-10T00:00:00+00:00' + ] + ] + ]; + + $this->assertSame($expected, $normalizer->normalize($subscriber)); + } + + public function testNormalizeWithInvalidObject(): void + { + $normalizer = new SubscriberNormalizer(); + $this->assertSame([], $normalizer->normalize(new stdClass())); + } +} diff --git a/tests/Unit/Serializer/SubscriptionNormalizerTest.php b/tests/Unit/Serializer/SubscriptionNormalizerTest.php new file mode 100644 index 0000000..4a2e446 --- /dev/null +++ b/tests/Unit/Serializer/SubscriptionNormalizerTest.php @@ -0,0 +1,67 @@ +createMock(SubscriberNormalizer::class), + $this->createMock(SubscriberListNormalizer::class) + ); + + $subscription = $this->createMock(Subscription::class); + $this->assertTrue($normalizer->supportsNormalization($subscription)); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalize(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriberList = $this->createMock(SubscriberList::class); + $subscriptionDate = new DateTime('2025-01-01T12:00:00+00:00'); + + $subscription = $this->createMock(Subscription::class); + $subscription->method('getSubscriber')->willReturn($subscriber); + $subscription->method('getSubscriberList')->willReturn($subscriberList); + $subscription->method('getCreationDate')->willReturn($subscriptionDate); + + $subscriberNormalizer = $this->createMock(SubscriberNormalizer::class); + $subscriberListNormalizer = $this->createMock(SubscriberListNormalizer::class); + + $subscriberNormalizer->method('normalize')->with($subscriber)->willReturn(['subscriber_data']); + $subscriberListNormalizer->method('normalize')->with($subscriberList)->willReturn(['list_data']); + + $normalizer = new SubscriptionNormalizer($subscriberNormalizer, $subscriberListNormalizer); + + $result = $normalizer->normalize($subscription); + + $this->assertSame([ + 'subscriber' => ['subscriber_data'], + 'subscriber_list' => ['list_data'], + 'subscription_date' => '2025-01-01T12:00:00+00:00', + ], $result); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $normalizer = new SubscriptionNormalizer( + $this->createMock(SubscriberNormalizer::class), + $this->createMock(SubscriberListNormalizer::class) + ); + + $this->assertSame([], $normalizer->normalize(new \stdClass())); + } +} diff --git a/tests/Unit/Service/Manager/MessageManagerTest.php b/tests/Unit/Service/Manager/MessageManagerTest.php new file mode 100644 index 0000000..1c0a2a3 --- /dev/null +++ b/tests/Unit/Service/Manager/MessageManagerTest.php @@ -0,0 +1,75 @@ +createMock(MessageRepository::class); + $templateRepository = $this->createMock(TemplateRepository::class); + + $manager = new MessageManager($messageRepository, $templateRepository); + + $format = new MessageFormatRequest(); + $format->htmlFormated = true; + $format->sendFormat = 'html'; + $format->formatOptions = ['html']; + + $schedule = new MessageScheduleRequest(); + $schedule->repeatInterval = 60 * 24; + $schedule->repeatUntil = '2025-04-30T00:00:00+00:00'; + $schedule->requeueInterval = 60 * 12; + $schedule->requeueUntil = '2025-04-20T00:00:00+00:00'; + $schedule->embargo = '2025-04-17T09:00:00+00:00'; + + $metadata = new MessageMetadataRequest(); + $metadata->status = 'draft'; + + $content = new MessageContentRequest(); + $content->subject = 'Subject'; + $content->text = 'Full text'; + $content->textMessage = 'Short text'; + $content->footer = 'Footer'; + + $options = new MessageOptionsRequest(); + $options->fromField = 'from@example.com'; + $options->toField = 'to@example.com'; + $options->replyTo = 'reply@example.com'; + $options->userSelection = 'all-users'; + + $request = new CreateMessageRequest(); + $request->format = $format; + $request->schedule = $schedule; + $request->metadata = $metadata; + $request->content = $content; + $request->options = $options; + $request->templateId = 0; + + $authUser = $this->createMock(Administrator::class); + + $messageRepository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Message::class)); + + $message = $manager->createMessage($request, $authUser); + + $this->assertSame('Subject', $message->getContent()->getSubject()); + $this->assertSame('draft', $message->getMetadata()->getStatus()); + } +} diff --git a/tests/Unit/Service/Manager/SessionManagerTest.php b/tests/Unit/Service/Manager/SessionManagerTest.php new file mode 100644 index 0000000..9489c24 --- /dev/null +++ b/tests/Unit/Service/Manager/SessionManagerTest.php @@ -0,0 +1,54 @@ +loginName = 'admin'; + $request->password = 'wrong'; + + $adminRepo = $this->createMock(AdministratorRepository::class); + $adminRepo->expects(self::once()) + ->method('findOneByLoginCredentials') + ->with('admin', 'wrong') + ->willReturn(null); + + $tokenRepo = $this->createMock(AdministratorTokenRepository::class); + $tokenRepo->expects(self::never())->method('save'); + + $manager = new SessionManager($tokenRepo, $adminRepo); + + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Not authorized'); + + $manager->createSession($request); + } + + public function testDeleteSessionCallsRemove(): void + { + $token = $this->createMock(AdministratorToken::class); + + $tokenRepo = $this->createMock(AdministratorTokenRepository::class); + $tokenRepo->expects(self::once()) + ->method('remove') + ->with($token); + + $adminRepo = $this->createMock(AdministratorRepository::class); + + $manager = new SessionManager($tokenRepo, $adminRepo); + $manager->deleteSession($token); + } +} diff --git a/tests/Unit/Service/Manager/SubscriberListManagerTest.php b/tests/Unit/Service/Manager/SubscriberListManagerTest.php new file mode 100644 index 0000000..41d78ae --- /dev/null +++ b/tests/Unit/Service/Manager/SubscriberListManagerTest.php @@ -0,0 +1,75 @@ +subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $this->manager = new SubscriberListManager($this->subscriberListRepository); + } + + public function testCreateSubscriberList(): void + { + $request = new CreateSubscriberListRequest(); + $request->name = 'New List'; + $request->description = 'Description'; + $request->listPosition = 3; + $request->public = true; + + $admin = new Administrator(); + + $this->subscriberListRepository + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(SubscriberList::class)); + + $result = $this->manager->createSubscriberList($request, $admin); + + $this->assertSame('New List', $result->getName()); + $this->assertSame('Description', $result->getDescription()); + $this->assertSame(3, $result->getListPosition()); + $this->assertTrue($result->isPublic()); + $this->assertSame($admin, $result->getOwner()); + } + + public function testGetAll(): void + { + $list = new SubscriberList(); + $this->subscriberListRepository + ->expects($this->once()) + ->method('findAll') + ->willReturn([$list]); + + $result = $this->manager->getAll(); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertSame($list, $result[0]); + } + + public function testDeleteSubscriberList(): void + { + $subscriberList = new SubscriberList(); + + $this->subscriberListRepository + ->expects($this->once()) + ->method('remove') + ->with($subscriberList); + + $this->manager->delete($subscriberList); + } +} diff --git a/tests/Integration/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Service/Manager/SubscriberManagerTest.php similarity index 96% rename from tests/Integration/Service/Manager/SubscriberManagerTest.php rename to tests/Unit/Service/Manager/SubscriberManagerTest.php index e1058a5..a159d91 100644 --- a/tests/Integration/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Service/Manager/SubscriberManagerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Service\Manager; +namespace PhpList\RestBundle\Tests\Unit\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Model\Subscription\Subscriber; diff --git a/tests/Unit/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Service/Manager/SubscriptionManagerTest.php new file mode 100644 index 0000000..7d3f416 --- /dev/null +++ b/tests/Unit/Service/Manager/SubscriptionManagerTest.php @@ -0,0 +1,106 @@ +subscriptionRepository = $this->createMock(SubscriptionRepository::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->manager = new SubscriptionManager($this->subscriptionRepository, $this->subscriberRepository); + } + + public function testCreateSubscriptionWhenSubscriberExists(): void + { + $email = 'test@example.com'; + $subscriber = new Subscriber(); + $list = new SubscriberList(); + + $this->subscriberRepository->method('findOneBy')->with(['email' => $email])->willReturn($subscriber); + $this->subscriptionRepository->method('findOneBySubscriberListAndSubscriber')->willReturn(null); + $this->subscriptionRepository->expects($this->once())->method('save'); + + $subscriptions = $this->manager->createSubscriptions($list, [$email]); + + $this->assertCount(1, $subscriptions); + $this->assertInstanceOf(Subscription::class, $subscriptions[0]); + } + + public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void + { + $this->expectException(SubscriptionCreationException::class); + $this->expectExceptionMessage('Subscriber does not exists.'); + + $list = new SubscriberList(); + + $this->subscriberRepository->method('findOneBy')->willReturn(null); + + $this->manager->createSubscriptions($list, ['missing@example.com']); + } + + public function testDeleteSubscriptionSuccessfully(): void + { + $email = 'user@example.com'; + $subscriberList = $this->createMock(SubscriberList::class); + $subscriberList->method('getId')->willReturn(1); + $subscription = new Subscription(); + + $this->subscriptionRepository + ->method('findOneBySubscriberEmailAndListId') + ->with($subscriberList->getId(), $email) + ->willReturn($subscription); + + $this->subscriptionRepository->expects($this->once())->method('remove')->with($subscription); + + $this->manager->deleteSubscriptions($subscriberList, [$email]); + } + + public function testDeleteSubscriptionSkipsNotFound(): void + { + $email = 'missing@example.com'; + $subscriberList = $this->createMock(SubscriberList::class); + $subscriberList->method('getId')->willReturn(1); + + $this->subscriptionRepository + ->method('findOneBySubscriberEmailAndListId') + ->willReturn(null); + + $this->manager->deleteSubscriptions($subscriberList, [$email]); + + $this->addToAssertionCount(1); // if no exception thrown, test is OK + } + + public function testGetSubscriberListMembersReturnsList(): void + { + $subscriberList = $this->createMock(SubscriberList::class); + $subscriberList->method('getId')->willReturn(1); + $subscriber = new Subscriber(); + + $this->subscriberRepository + ->method('getSubscribersBySubscribedListId') + ->with($subscriberList->getId()) + ->willReturn([$subscriber]); + + $result = $this->manager->getSubscriberListMembers($subscriberList); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertInstanceOf(Subscriber::class, $result[0]); + } +} diff --git a/tests/Unit/Service/Provider/MessageProviderTest.php b/tests/Unit/Service/Provider/MessageProviderTest.php new file mode 100644 index 0000000..68127b5 --- /dev/null +++ b/tests/Unit/Service/Provider/MessageProviderTest.php @@ -0,0 +1,39 @@ +createMock(Message::class); + $message2 = $this->createMock(Message::class); + $expectedMessages = [$message1, $message2]; + + $repository = $this->createMock(MessageRepository::class); + $repository->expects($this->once()) + ->method('getByOwnerId') + ->with($ownerId) + ->willReturn($expectedMessages); + + $owner = $this->createMock(Administrator::class); + $owner->expects($this->once()) + ->method('getId') + ->willReturn($ownerId); + + $provider = new MessageProvider($repository); + + $result = $provider->getMessagesByOwner($owner); + + $this->assertSame($expectedMessages, $result); + } +} diff --git a/tests/Unit/Validator/EmailExistsValidatorTest.php b/tests/Unit/Validator/EmailExistsValidatorTest.php new file mode 100644 index 0000000..6395ff0 --- /dev/null +++ b/tests/Unit/Validator/EmailExistsValidatorTest.php @@ -0,0 +1,85 @@ +subscriberRepository = $this->createMock(SubscriberRepository::class); + $context = $this->createMock(ExecutionContextInterface::class); + + $this->validator = new EmailExistsValidator($this->subscriberRepository); + $this->validator->initialize($context); + } + + public function testValidateSkipsNull(): void + { + $this->subscriberRepository->expects($this->never())->method('findOneBy'); + $this->validator->validate(null, new EmailExists()); + $this->assertTrue(true); // to mark test as passed + } + + public function testValidateSkipsEmptyString(): void + { + $this->subscriberRepository->expects($this->never())->method('findOneBy'); + $this->validator->validate('', new EmailExists()); + $this->assertTrue(true); + } + + public function testValidateThrowsUnexpectedTypeException(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate('test@example.com', $this->createMock(Constraint::class)); + } + + public function testValidateThrowsUnexpectedValueException(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(123, new EmailExists()); + } + + public function testValidateThrowsNotFoundExceptionIfEmailDoesNotExist(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['email' => 'missing@example.com']) + ->willReturn(null); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Subscriber with email does not exists.'); + + $this->validator->validate('missing@example.com', new EmailExists()); + } + + public function testValidatePassesIfEmailExists(): void + { + $subscriber = $this->createMock(Subscriber::class); + + $this->subscriberRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['email' => 'found@example.com']) + ->willReturn($subscriber); + + $this->validator->validate('found@example.com', new EmailExists()); + $this->assertTrue(true); + } +} diff --git a/tests/Integration/Validator/RequestValidatorTest.php b/tests/Unit/Validator/RequestValidatorTest.php similarity index 98% rename from tests/Integration/Validator/RequestValidatorTest.php rename to tests/Unit/Validator/RequestValidatorTest.php index 51952b4..0b4e12c 100644 --- a/tests/Integration/Validator/RequestValidatorTest.php +++ b/tests/Unit/Validator/RequestValidatorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Validator; +namespace PhpList\RestBundle\Tests\Unit\Validator; use PhpList\RestBundle\Entity\Request\RequestInterface; use PhpList\RestBundle\Tests\Helpers\DummyRequestDto; diff --git a/tests/Unit/Validator/TemplateExistsValidatorTest.php b/tests/Unit/Validator/TemplateExistsValidatorTest.php new file mode 100644 index 0000000..a321bee --- /dev/null +++ b/tests/Unit/Validator/TemplateExistsValidatorTest.php @@ -0,0 +1,85 @@ +templateRepository = $this->createMock(TemplateRepository::class); + $context = $this->createMock(ExecutionContextInterface::class); + + $this->validator = new TemplateExistsValidator($this->templateRepository); + $this->validator->initialize($context); + } + + public function testValidateSkipsNull(): void + { + $this->templateRepository->expects($this->never())->method('find'); + $this->validator->validate(null, new TemplateExists()); + $this->assertTrue(true); + } + + public function testValidateSkipsEmptyString(): void + { + $this->templateRepository->expects($this->never())->method('find'); + $this->validator->validate('', new TemplateExists()); + $this->assertTrue(true); + } + + public function testValidateThrowsUnexpectedTypeException(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate(1, $this->createMock(Constraint::class)); + } + + public function testValidateThrowsUnexpectedValueException(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate('not-an-int', new TemplateExists()); + } + + public function testValidateThrowsConflictHttpExceptionIfTemplateDoesNotExist(): void + { + $this->templateRepository + ->expects($this->once()) + ->method('find') + ->with(999) + ->willReturn(null); + + $this->expectException(ConflictHttpException::class); + $this->expectExceptionMessage('Template with that id does not exists.'); + + $this->validator->validate(999, new TemplateExists()); + } + + public function testValidatePassesIfTemplateExists(): void + { + $template = $this->createMock(Template::class); + + $this->templateRepository + ->expects($this->once()) + ->method('find') + ->with(1) + ->willReturn($template); + + $this->validator->validate(1, new TemplateExists()); + $this->assertTrue(true); + } +} diff --git a/tests/Integration/Validator/UniqueEmailValidatorTest.php b/tests/Unit/Validator/UniqueEmailValidatorTest.php similarity index 98% rename from tests/Integration/Validator/UniqueEmailValidatorTest.php rename to tests/Unit/Validator/UniqueEmailValidatorTest.php index c1c9942..6ec2f1d 100644 --- a/tests/Integration/Validator/UniqueEmailValidatorTest.php +++ b/tests/Unit/Validator/UniqueEmailValidatorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Integration\Validator; +namespace PhpList\RestBundle\Tests\Unit\Validator; use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; @@ -11,8 +11,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; -use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedValueException; From 49a8926c1207f85083d3358469e0f25ebf85ab46 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 20 Apr 2025 12:35:15 +0400 Subject: [PATCH 39/62] ISSUE-345: phpstan + phpcs fix --- .../Integration/Controller/SubscriptionControllerTest.php | 2 +- tests/Unit/Service/Manager/SubscriberListManagerTest.php | 3 ++- tests/Unit/Service/Manager/SubscriptionManagerTest.php | 7 ++++--- tests/Unit/Validator/EmailExistsValidatorTest.php | 5 +++-- tests/Unit/Validator/TemplateExistsValidatorTest.php | 3 ++- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/Integration/Controller/SubscriptionControllerTest.php b/tests/Integration/Controller/SubscriptionControllerTest.php index 4ce2c3b..c8b73fc 100644 --- a/tests/Integration/Controller/SubscriptionControllerTest.php +++ b/tests/Integration/Controller/SubscriptionControllerTest.php @@ -54,7 +54,7 @@ public function testCreateSubscriptionWithValidEmailsReturns201(): void $payload = json_encode(['emails' => ['oliver@example.com']]); - $this->authenticatedJsonRequest('POST', '/api/v2/lists/1/subscribers', [], [], [], $payload); + $this->authenticatedJsonRequest('POST', '/api/v2/lists/1/subscribers', [], [], [], $payload); $this->assertHttpCreated(); } diff --git a/tests/Unit/Service/Manager/SubscriberListManagerTest.php b/tests/Unit/Service/Manager/SubscriberListManagerTest.php index 41d78ae..7659853 100644 --- a/tests/Unit/Service/Manager/SubscriberListManagerTest.php +++ b/tests/Unit/Service/Manager/SubscriberListManagerTest.php @@ -9,11 +9,12 @@ use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; use PhpList\RestBundle\Service\Manager\SubscriberListManager; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class SubscriberListManagerTest extends TestCase { - private SubscriberListRepository $subscriberListRepository; + private SubscriberListRepository&MockObject $subscriberListRepository; private SubscriberListManager $manager; protected function setUp(): void diff --git a/tests/Unit/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Service/Manager/SubscriptionManagerTest.php index 7d3f416..729deaa 100644 --- a/tests/Unit/Service/Manager/SubscriptionManagerTest.php +++ b/tests/Unit/Service/Manager/SubscriptionManagerTest.php @@ -11,12 +11,13 @@ use PhpList\Core\Domain\Repository\Subscription\SubscriptionRepository; use PhpList\RestBundle\Exception\SubscriptionCreationException; use PhpList\RestBundle\Service\Manager\SubscriptionManager; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class SubscriptionManagerTest extends TestCase { - private SubscriptionRepository $subscriptionRepository; - private SubscriberRepository $subscriberRepository; + private SubscriptionRepository&MockObject $subscriptionRepository; + private SubscriberRepository&MockObject $subscriberRepository; private SubscriptionManager $manager; protected function setUp(): void @@ -83,7 +84,7 @@ public function testDeleteSubscriptionSkipsNotFound(): void $this->manager->deleteSubscriptions($subscriberList, [$email]); - $this->addToAssertionCount(1); // if no exception thrown, test is OK + $this->addToAssertionCount(1); } public function testGetSubscriberListMembersReturnsList(): void diff --git a/tests/Unit/Validator/EmailExistsValidatorTest.php b/tests/Unit/Validator/EmailExistsValidatorTest.php index 6395ff0..995efbc 100644 --- a/tests/Unit/Validator/EmailExistsValidatorTest.php +++ b/tests/Unit/Validator/EmailExistsValidatorTest.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\RestBundle\Validator\EmailExists; use PhpList\RestBundle\Validator\EmailExistsValidator; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -17,7 +18,7 @@ class EmailExistsValidatorTest extends TestCase { - private SubscriberRepository $subscriberRepository; + private SubscriberRepository&MockObject $subscriberRepository; private EmailExistsValidator $validator; protected function setUp(): void @@ -33,7 +34,7 @@ public function testValidateSkipsNull(): void { $this->subscriberRepository->expects($this->never())->method('findOneBy'); $this->validator->validate(null, new EmailExists()); - $this->assertTrue(true); // to mark test as passed + $this->assertTrue(true); } public function testValidateSkipsEmptyString(): void diff --git a/tests/Unit/Validator/TemplateExistsValidatorTest.php b/tests/Unit/Validator/TemplateExistsValidatorTest.php index a321bee..38f6304 100644 --- a/tests/Unit/Validator/TemplateExistsValidatorTest.php +++ b/tests/Unit/Validator/TemplateExistsValidatorTest.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use PhpList\RestBundle\Validator\TemplateExists; use PhpList\RestBundle\Validator\TemplateExistsValidator; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -17,7 +18,7 @@ class TemplateExistsValidatorTest extends TestCase { - private TemplateRepository $templateRepository; + private TemplateRepository&MockObject $templateRepository; private TemplateExistsValidator $validator; protected function setUp(): void From 09a1ff7d4472d8321ce879b3104a019e1e147af5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 21 Apr 2025 23:29:39 +0400 Subject: [PATCH 40/62] ISSUE-345: update message endpoint --- config/services/builders.yml | 25 +++ src/Controller/CampaignController.php | 155 ++++++++++-------- src/Entity/Dto/MessageContext.php | 25 +++ .../Request/Message/MessageContentRequest.php | 2 +- .../Request/Message/MessageFormatRequest.php | 2 +- .../Message/MessageMetadataRequest.php | 2 +- .../Request/Message/MessageOptionsRequest.php | 2 +- .../Message/MessageScheduleRequest.php | 2 +- .../Request/Message/RequestDtoInterface.php | 8 + src/Entity/Request/UpdateMessageRequest.php | 36 ++++ src/OpenApi/SwaggerSchemasRequestDto.php | 78 +++++++++ ...y.php => SwaggerSchemasResponseEntity.php} | 2 +- .../Builder/BuilderFromDtoInterface.php | 11 ++ .../Builder/BuilderFromRequestInterface.php | 11 ++ src/Service/Builder/MessageBuilder.php | 56 +++++++ src/Service/Builder/MessageContentBuilder.php | 26 +++ src/Service/Builder/MessageFormatBuilder.php | 25 +++ src/Service/Builder/MessageOptionsBuilder.php | 27 +++ .../Builder/MessageScheduleBuilder.php | 28 ++++ src/Service/Manager/MessageManager.php | 59 ++----- .../Fixtures/Messaging/MessageFixture.php | 2 +- .../Service/Manager/MessageManagerTest.php | 23 ++- 22 files changed, 483 insertions(+), 124 deletions(-) create mode 100644 config/services/builders.yml create mode 100644 src/Entity/Dto/MessageContext.php create mode 100644 src/Entity/Request/Message/RequestDtoInterface.php create mode 100644 src/Entity/Request/UpdateMessageRequest.php create mode 100644 src/OpenApi/SwaggerSchemasRequestDto.php rename src/OpenApi/{SwaggerSchemasEntity.php => SwaggerSchemasResponseEntity.php} (99%) create mode 100644 src/Service/Builder/BuilderFromDtoInterface.php create mode 100644 src/Service/Builder/BuilderFromRequestInterface.php create mode 100644 src/Service/Builder/MessageBuilder.php create mode 100644 src/Service/Builder/MessageContentBuilder.php create mode 100644 src/Service/Builder/MessageFormatBuilder.php create mode 100644 src/Service/Builder/MessageOptionsBuilder.php create mode 100644 src/Service/Builder/MessageScheduleBuilder.php diff --git a/config/services/builders.yml b/config/services/builders.yml new file mode 100644 index 0000000..e37a342 --- /dev/null +++ b/config/services/builders.yml @@ -0,0 +1,25 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Service\Builder\MessageBuilder: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Builder\MessageFormatBuilder: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Builder\MessageScheduleBuilder: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Builder\MessageContentBuilder: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Builder\MessageOptionsBuilder: + autowire: true + autoconfigure: true diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 89c8d3a..8c57747 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -9,6 +9,7 @@ use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateMessageRequest; +use PhpList\RestBundle\Entity\Request\UpdateMessageRequest; use PhpList\RestBundle\Serializer\MessageNormalizer; use PhpList\RestBundle\Service\Manager\MessageManager; use PhpList\RestBundle\Service\Provider\MessageProvider; @@ -19,6 +20,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Serializer\SerializerInterface; /** * This controller provides REST API to manage campaigns. @@ -151,78 +153,11 @@ public function getMessage( content: new OA\JsonContent( properties: [ new OA\Property(property: 'template_id', type: 'integer', example: 1), - new OA\Property( - property: 'message_content', - 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' - ), - new OA\Property( - property: 'message_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' - ), - new OA\Property( - property: 'message_metadata', - properties: [ - new OA\Property(property: 'status', type: 'string', example: 'draft'), - ], - type: 'object' - ), - new OA\Property( - property: 'message_schedule', - properties: [ - new OA\Property( - property: 'embargo', - type: 'string', - format: 'date-time', - example: '2025-04-17 09:00:00' - ), - new OA\Property(property: 'repeat_interval', type: 'string', example: '24 hours'), - new OA\Property( - property: 'repeat_until', - type: 'string', - format: 'date-time', - example: '2025-04-30T00:00:00+04:00' - ), - new OA\Property(property: 'requeue_interval', type: 'string', example: '12 hours'), - new OA\Property( - property: 'requeue_until', - type: 'string', - format: 'date-time', - example: '2025-04-20T00:00:00+04:00' - ), - ], - type: 'object' - ), - new OA\Property( - property: 'message_options', - properties: [ - new OA\Property(property: 'from_field', type: 'string', example: 'info@example.com'), - new OA\Property(property: 'to_field', type: 'string', example: 'subscriber@example.com'), - new OA\Property(property: 'reply_to', type: 'string', example: 'reply@example.com'), - new OA\Property(property: 'user_selection', type: 'string', example: 'all-active-users'), - ], - type: 'object' - ), + new OA\Property(property: 'message_content', ref: '#/components/schemas/MessageContentRequest'), + new OA\Property(property: 'message_format', ref: '#/components/schemas/MessageFormatRequest'), + new OA\Property(property: 'message_metadata', ref: '#/components/schemas/MessageMetadataRequest'), + new OA\Property(property: 'message_schedule', ref: '#/components/schemas/MessageScheduleRequest'), + new OA\Property(property: 'message_options', ref: '#/components/schemas/MessageOptionsRequest'), ], type: 'object' ) @@ -267,4 +202,80 @@ public function createMessage(Request $request, MessageNormalizer $normalizer): return new JsonResponse($normalizer->normalize($data), Response::HTTP_CREATED); } + + #[Route('/{messageId}', name: 'update_campaign', methods: ['PUT'])] + #[OA\Put( + path: '/campaigns/{messageId}', + description: 'Updates campaign/message by id.', + summary: 'Update campaign by id.', + requestBody: new OA\RequestBody( + description: 'Update message.', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'template_id', type: 'integer', example: 1), + new OA\Property(property: 'message_content', ref: '#/components/schemas/MessageContentRequest'), + new OA\Property(property: 'message_format', ref: '#/components/schemas/MessageFormatRequest'), + new OA\Property(property: 'message_schedule', ref: '#/components/schemas/MessageScheduleRequest'), + new OA\Property(property: 'message_options', ref: '#/components/schemas/MessageOptionsRequest'), + ], + type: 'object' + ) + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + 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: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function updateMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] Message $message, + SerializerInterface $serializer, + ): JsonResponse { + $authUser = $this->requireAuthentication($request); + + /** @return UpdateMessageRequest $updateMessageRequest */ + $updateMessageRequest = $serializer->deserialize($request->getContent(), UpdateMessageRequest::class, 'json'); + $updateMessageRequest->messageId = $message->getId(); + $this->validator->validateDto($updateMessageRequest); + + return new JsonResponse( + $this->normalizer->normalize( + $this->messageManager->updateMessage($updateMessageRequest, $message, $authUser) + ), + Response::HTTP_OK + ); + } } diff --git a/src/Entity/Dto/MessageContext.php b/src/Entity/Dto/MessageContext.php new file mode 100644 index 0000000..594834a --- /dev/null +++ b/src/Entity/Dto/MessageContext.php @@ -0,0 +1,25 @@ +user; + } + + public function getExisting(): ?Message + { + return $this->existing; + } +} diff --git a/src/Entity/Request/Message/MessageContentRequest.php b/src/Entity/Request/Message/MessageContentRequest.php index b64091b..2e94c0f 100644 --- a/src/Entity/Request/Message/MessageContentRequest.php +++ b/src/Entity/Request/Message/MessageContentRequest.php @@ -6,7 +6,7 @@ use Symfony\Component\Validator\Constraints as Assert; -class MessageContentRequest +class MessageContentRequest implements RequestDtoInterface { #[Assert\NotBlank] public string $subject; diff --git a/src/Entity/Request/Message/MessageFormatRequest.php b/src/Entity/Request/Message/MessageFormatRequest.php index 1cf91bd..c46f954 100644 --- a/src/Entity/Request/Message/MessageFormatRequest.php +++ b/src/Entity/Request/Message/MessageFormatRequest.php @@ -6,7 +6,7 @@ use Symfony\Component\Validator\Constraints as Assert; -class MessageFormatRequest +class MessageFormatRequest implements RequestDtoInterface { #[Assert\Type('bool')] public bool $htmlFormated; diff --git a/src/Entity/Request/Message/MessageMetadataRequest.php b/src/Entity/Request/Message/MessageMetadataRequest.php index 9a0fbb6..9720b66 100644 --- a/src/Entity/Request/Message/MessageMetadataRequest.php +++ b/src/Entity/Request/Message/MessageMetadataRequest.php @@ -6,7 +6,7 @@ use Symfony\Component\Validator\Constraints as Assert; -class MessageMetadataRequest +class MessageMetadataRequest implements RequestDtoInterface { #[Assert\NotBlank] public string $status; diff --git a/src/Entity/Request/Message/MessageOptionsRequest.php b/src/Entity/Request/Message/MessageOptionsRequest.php index 71aebcc..52b999c 100644 --- a/src/Entity/Request/Message/MessageOptionsRequest.php +++ b/src/Entity/Request/Message/MessageOptionsRequest.php @@ -6,7 +6,7 @@ use Symfony\Component\Validator\Constraints as Assert; -class MessageOptionsRequest +class MessageOptionsRequest implements RequestDtoInterface { #[Assert\Email] public string $fromField; diff --git a/src/Entity/Request/Message/MessageScheduleRequest.php b/src/Entity/Request/Message/MessageScheduleRequest.php index f5cc63e..a3f2c05 100644 --- a/src/Entity/Request/Message/MessageScheduleRequest.php +++ b/src/Entity/Request/Message/MessageScheduleRequest.php @@ -6,7 +6,7 @@ use Symfony\Component\Validator\Constraints as Assert; -class MessageScheduleRequest +class MessageScheduleRequest implements RequestDtoInterface { #[Assert\NotBlank] public int $repeatInterval; diff --git a/src/Entity/Request/Message/RequestDtoInterface.php b/src/Entity/Request/Message/RequestDtoInterface.php new file mode 100644 index 0000000..403344d --- /dev/null +++ b/src/Entity/Request/Message/RequestDtoInterface.php @@ -0,0 +1,8 @@ +messageFormatBuilder->buildFromDto($request->format); + $schedule = $this->messageScheduleBuilder->buildFromDto($request->schedule); + $content = $this->messageContentBuilder->buildFromDto($request->content); + $options = $this->messageOptionsBuilder->buildFromDto($request->options); + $template = null; + if ($request->templateId > 0) { + $template = $this->templateRepository->find($request->templateId); + } + + if ($context->getExisting()) { + $context->getExisting()->setFormat($format); + $context->getExisting()->setSchedule($schedule); + $context->getExisting()->setContent($content); + $context->getExisting()->setOptions($options); + $context->getExisting()->setTemplate($template); + return $context->getExisting(); + } + + $metadata = new Message\MessageMetadata($request->metadata->status); + + return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template); + } +} diff --git a/src/Service/Builder/MessageContentBuilder.php b/src/Service/Builder/MessageContentBuilder.php new file mode 100644 index 0000000..e3c8b47 --- /dev/null +++ b/src/Service/Builder/MessageContentBuilder.php @@ -0,0 +1,26 @@ +subject, + $dto->text, + $dto->textMessage, + $dto->footer + ); + } +} diff --git a/src/Service/Builder/MessageFormatBuilder.php b/src/Service/Builder/MessageFormatBuilder.php new file mode 100644 index 0000000..f5d43b0 --- /dev/null +++ b/src/Service/Builder/MessageFormatBuilder.php @@ -0,0 +1,25 @@ +htmlFormated, + $dto->sendFormat, + $dto->formatOptions + ); + } +} diff --git a/src/Service/Builder/MessageOptionsBuilder.php b/src/Service/Builder/MessageOptionsBuilder.php new file mode 100644 index 0000000..8327157 --- /dev/null +++ b/src/Service/Builder/MessageOptionsBuilder.php @@ -0,0 +1,27 @@ +fromField ?? '', + $dto->toField ?? '', + $dto->replyTo ?? '', + $dto->userSelection, + null, + ); + } +} diff --git a/src/Service/Builder/MessageScheduleBuilder.php b/src/Service/Builder/MessageScheduleBuilder.php new file mode 100644 index 0000000..95463ca --- /dev/null +++ b/src/Service/Builder/MessageScheduleBuilder.php @@ -0,0 +1,28 @@ +repeatInterval, + new DateTime($dto->repeatUntil), + $dto->requeueInterval, + new DateTime($dto->requeueUntil), + new DateTime($dto->embargo) + ); + } +} diff --git a/src/Service/Manager/MessageManager.php b/src/Service/Manager/MessageManager.php index 7286be7..ea7e76f 100644 --- a/src/Service/Manager/MessageManager.php +++ b/src/Service/Manager/MessageManager.php @@ -4,64 +4,41 @@ namespace PhpList\RestBundle\Service\Manager; -use DateTime; use PhpList\Core\Domain\Model\Identity\Administrator; use PhpList\Core\Domain\Model\Messaging\Message; use PhpList\Core\Domain\Repository\Messaging\MessageRepository; -use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; +use PhpList\RestBundle\Entity\Dto\MessageContext; use PhpList\RestBundle\Entity\Request\CreateMessageRequest; +use PhpList\RestBundle\Entity\Request\UpdateMessageRequest; +use PhpList\RestBundle\Service\Builder\MessageBuilder; class MessageManager { private MessageRepository $messageRepository; - private TemplateRepository $templateRepository; + private MessageBuilder $messageBuilder; - public function __construct(MessageRepository $messageRepository, TemplateRepository $templateRepository) + public function __construct(MessageRepository $messageRepository, MessageBuilder $messageBuilder) { $this->messageRepository = $messageRepository; - $this->templateRepository = $templateRepository; + $this->messageBuilder = $messageBuilder; } public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $authUser): Message { - $format = new Message\MessageFormat( - $createMessageRequest->format->htmlFormated, - $createMessageRequest->format->sendFormat, - $createMessageRequest->format->formatOptions, - ); - - $schedule = new Message\MessageSchedule( - $createMessageRequest->schedule->repeatInterval, - new DateTime($createMessageRequest->schedule->repeatUntil), - $createMessageRequest->schedule->requeueInterval, - new DateTime($createMessageRequest->schedule->requeueUntil), - new DateTime($createMessageRequest->schedule->embargo), - ); - - $metadata = new Message\MessageMetadata($createMessageRequest->metadata->status); - - $content = new Message\MessageContent( - $createMessageRequest->content->subject, - $createMessageRequest->content->text, - $createMessageRequest->content->textMessage, - $createMessageRequest->content->footer, - ); - - $options = new Message\MessageOptions( - $createMessageRequest->options->fromField ?? '', - $createMessageRequest->options->toField ?? '', - $createMessageRequest->options->replyTo ?? '', - $createMessageRequest->options->userSelection, - null, - null - ); - - if ($createMessageRequest->templateId > 0) { - $template = $this->templateRepository->find($createMessageRequest->templateId); - } + $context = new MessageContext($authUser); + $message = $this->messageBuilder->buildFromRequest($createMessageRequest, $context); + $this->messageRepository->save($message); - $message = new Message($format, $schedule, $metadata, $content, $options, $authUser, $template ?? null); + return $message; + } + public function updateMessage( + UpdateMessageRequest $updateMessageRequest, + Message $message, + Administrator $authUser + ): Message { + $context = new MessageContext($authUser, $message); + $message = $this->messageBuilder->buildFromRequest($updateMessageRequest, $context); $this->messageRepository->save($message); return $message; diff --git a/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php b/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php index 800fa91..fbe6f83 100644 --- a/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php +++ b/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php @@ -70,6 +70,7 @@ public function load(ObjectManager $manager): void (int)$row['bouncecount'], new DateTime($row['entered']), new DateTime($row['sent']), + new DateTime($row['sendstart']), ); $metadata->setProcessed((bool) $row['processed']); $metadata->setViews((int)$row['viewed']); @@ -84,7 +85,6 @@ public function load(ObjectManager $manager): void $row['tofield'], $row['replyto'], $row['userselection'], - new DateTime($row['sendstart']), $row['rsstemplate'], ); diff --git a/tests/Unit/Service/Manager/MessageManagerTest.php b/tests/Unit/Service/Manager/MessageManagerTest.php index 1c0a2a3..99dbfe4 100644 --- a/tests/Unit/Service/Manager/MessageManagerTest.php +++ b/tests/Unit/Service/Manager/MessageManagerTest.php @@ -7,13 +7,13 @@ use PhpList\Core\Domain\Model\Identity\Administrator; use PhpList\Core\Domain\Model\Messaging\Message; use PhpList\Core\Domain\Repository\Messaging\MessageRepository; -use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use PhpList\RestBundle\Entity\Request\CreateMessageRequest; use PhpList\RestBundle\Entity\Request\Message\MessageContentRequest; use PhpList\RestBundle\Entity\Request\Message\MessageFormatRequest; use PhpList\RestBundle\Entity\Request\Message\MessageMetadataRequest; use PhpList\RestBundle\Entity\Request\Message\MessageOptionsRequest; use PhpList\RestBundle\Entity\Request\Message\MessageScheduleRequest; +use PhpList\RestBundle\Service\Builder\MessageBuilder; use PhpList\RestBundle\Service\Manager\MessageManager; use PHPUnit\Framework\TestCase; @@ -22,9 +22,9 @@ class MessageManagerTest extends TestCase public function testCreateMessageReturnsPersistedMessage(): void { $messageRepository = $this->createMock(MessageRepository::class); - $templateRepository = $this->createMock(TemplateRepository::class); + $messageBuilder = $this->createMock(MessageBuilder::class); - $manager = new MessageManager($messageRepository, $templateRepository); + $manager = new MessageManager($messageRepository, $messageBuilder); $format = new MessageFormatRequest(); $format->htmlFormated = true; @@ -63,9 +63,24 @@ public function testCreateMessageReturnsPersistedMessage(): void $authUser = $this->createMock(Administrator::class); + $expectedMessage = $this->createMock(Message::class); + $expectedContent = $this->createMock(Message\MessageContent::class); + $expectedMetadata = $this->createMock(Message\MessageMetadata::class); + + $expectedContent->method('getSubject')->willReturn('Subject'); + $expectedMetadata->method('getStatus')->willReturn('draft'); + + $expectedMessage->method('getContent')->willReturn($expectedContent); + $expectedMessage->method('getMetadata')->willReturn($expectedMetadata); + + $messageBuilder->expects($this->once()) + ->method('buildFromRequest') + ->with($request, $this->anything()) + ->willReturn($expectedMessage); + $messageRepository->expects($this->once()) ->method('save') - ->with($this->isInstanceOf(Message::class)); + ->with($expectedMessage); $message = $manager->createMessage($request, $authUser); From 3e082629f63d98f78da38a3a60dca1e923f8f8af Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 22 Apr 2025 20:25:30 +0400 Subject: [PATCH 41/62] ISSUE-345: template controller --- config/services/normalizers.yml | 8 ++ src/Controller/TemplateController.php | 85 +++++++++++++++++++ src/OpenApi/SwaggerSchemasResponseEntity.php | 27 ++++++ src/Serializer/MessageNormalizer.php | 13 ++- src/Serializer/TemplateImageNormalizer.php | 39 +++++++++ src/Serializer/TemplateNormalizer.php | 45 ++++++++++ .../Unit/Serializer/MessageNormalizerTest.php | 22 +++-- 7 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 src/Controller/TemplateController.php create mode 100644 src/Serializer/TemplateImageNormalizer.php create mode 100644 src/Serializer/TemplateNormalizer.php diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 521d52d..e809616 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -30,3 +30,11 @@ services: PhpList\RestBundle\Serializer\MessageNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Serializer\TemplateImageNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Serializer\TemplateNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php new file mode 100644 index 0000000..0e5b2b0 --- /dev/null +++ b/src/Controller/TemplateController.php @@ -0,0 +1,85 @@ + + */ +#[Route('/templates')] +class TemplateController extends AbstractController +{ + use AuthenticationTrait; + + private TemplateRepository $templateRepository; + private TemplateNormalizer $normalizer; + + public function __construct( + Authentication $authentication, + TemplateRepository $templateRepository, + TemplateNormalizer $normalizer, + ) { + $this->authentication = $authentication; + $this->templateRepository = $templateRepository; + $this->normalizer = $normalizer; + } + + #[Route('', name: 'get_templates', methods: ['GET'])] + #[OA\Get( + path: '/templates', + description: 'Returns a JSON list of all templates.', + summary: 'Gets a list of all templates.', + tags: ['templates'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Template') + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getTemplates(Request $request): JsonResponse + { + $this->requireAuthentication($request); + $data = $this->templateRepository->findAll(); + + $normalized = array_map(function ($item) { + return $this->normalizer->normalize($item); + }, $data); + + return new JsonResponse($normalized, Response::HTTP_OK); + } +} diff --git a/src/OpenApi/SwaggerSchemasResponseEntity.php b/src/OpenApi/SwaggerSchemasResponseEntity.php index 88bd562..d4331a7 100644 --- a/src/OpenApi/SwaggerSchemasResponseEntity.php +++ b/src/OpenApi/SwaggerSchemasResponseEntity.php @@ -61,6 +61,27 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'TemplateImage', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 12), + new OA\Property(property: 'template_id', type: 'integer', example: 1), + new OA\Property(property: 'mimetype', type: 'string', example: 'image/png', nullable: true), + new OA\Property(property: 'filename', type: 'string', example: 'header.png', nullable: true), + new OA\Property( + property: 'data', + description: 'Base64-encoded image data', + type: 'string', + format: 'byte', + example: 'iVBORw0KGgoAAAANSUhEUgAAA...', + nullable: true + ), + new OA\Property(property: 'width', type: 'integer', example: 600, nullable: true), + new OA\Property(property: 'height', type: 'integer', example: 200, nullable: true), + ], + type: 'object', + nullable: true +)] #[OA\Schema( schema: 'Template', properties: [ @@ -69,6 +90,12 @@ new OA\Property(property: 'template', type: 'string', example: 'Hello World!', nullable: true), new OA\Property(property: 'template_text', type: 'string', nullable: true), new OA\Property(property: 'order', type: 'integer', nullable: true), + new OA\Property( + property: 'images', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/TemplateImage'), + nullable: true + ), ], type: 'object', nullable: true diff --git a/src/Serializer/MessageNormalizer.php b/src/Serializer/MessageNormalizer.php index 449683c..dd3c5ba 100644 --- a/src/Serializer/MessageNormalizer.php +++ b/src/Serializer/MessageNormalizer.php @@ -9,6 +9,10 @@ class MessageNormalizer implements NormalizerInterface { + public function __construct(private readonly TemplateNormalizer $templateNormalizer) + { + } + /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ @@ -18,16 +22,11 @@ public function normalize($object, string $format = null, array $context = []): return []; } + $template = $object->getTemplate(); return [ 'id' => $object->getId(), 'unique_id' => $object->getUuid(), - 'template' => $object->getTemplate()?->getId() ? [ - 'id' => $object->getTemplate()->getId(), - 'title' => $object->getTemplate()->getTitle(), - 'template' => $object->getTemplate()->getTemplate(), - 'template_text' => $object->getTemplate()->getTemplateText(), - 'order' => $object->getTemplate()->getListOrder(), - ] : null, + 'template' => $template?->getId() ? $this->templateNormalizer->normalize($template) : null, 'message_content' => [ 'subject' => $object->getContent()->getSubject(), 'text' => $object->getContent()->getText(), diff --git a/src/Serializer/TemplateImageNormalizer.php b/src/Serializer/TemplateImageNormalizer.php new file mode 100644 index 0000000..2a84bab --- /dev/null +++ b/src/Serializer/TemplateImageNormalizer.php @@ -0,0 +1,39 @@ + $object->getId(), + 'template_id' => $object->getTemplate()?->getId(), + 'mimetype' => $object->getMimeType(), + 'filename' => $object->getFilename(), + 'data' => base64_encode($object->getData() ?? ''), + 'width' => $object->getWidth(), + 'height' => $object->getHeight(), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof TemplateImage; + } +} diff --git a/src/Serializer/TemplateNormalizer.php b/src/Serializer/TemplateNormalizer.php new file mode 100644 index 0000000..dcf7e83 --- /dev/null +++ b/src/Serializer/TemplateNormalizer.php @@ -0,0 +1,45 @@ + $object->getId(), + 'title' => $object->getTitle(), + 'template' => $object->getTemplate(), + 'template_text' => $object->getTemplateText(), + 'order' => $object->getListOrder(), + 'images' => $object->getImages()->toArray() ? array_map(function (TemplateImage $image) { + return $this->templateImageNormalizer->normalize($image); + }, $object->getImages()->toArray()) : null + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Template; + } +} diff --git a/tests/Unit/Serializer/MessageNormalizerTest.php b/tests/Unit/Serializer/MessageNormalizerTest.php index 85ec5b9..307975d 100644 --- a/tests/Unit/Serializer/MessageNormalizerTest.php +++ b/tests/Unit/Serializer/MessageNormalizerTest.php @@ -13,17 +13,25 @@ use PhpList\Core\Domain\Model\Messaging\Message\MessageSchedule; use PhpList\Core\Domain\Model\Messaging\Template; use PhpList\RestBundle\Serializer\MessageNormalizer; +use PhpList\RestBundle\Serializer\TemplateImageNormalizer; +use PhpList\RestBundle\Serializer\TemplateNormalizer; use PHPUnit\Framework\TestCase; class MessageNormalizerTest extends TestCase { - public function testSupportsNormalization(): void + private MessageNormalizer $normalizer; + + public function __construct(string $name) { - $normalizer = new MessageNormalizer(); + parent::__construct($name); + $this->normalizer = new MessageNormalizer(new TemplateNormalizer(new TemplateImageNormalizer())); + } + public function testSupportsNormalization(): void + { $message = $this->createMock(Message::class); - $this->assertTrue($normalizer->supportsNormalization($message)); - $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); + $this->assertTrue($this->normalizer->supportsNormalization($message)); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); } public function testNormalizeReturnsExpectedArray(): void @@ -70,8 +78,7 @@ public function testNormalizeReturnsExpectedArray(): void $message->method('getSchedule')->willReturn($schedule); $message->method('getOptions')->willReturn($options); - $normalizer = new MessageNormalizer(); - $result = $normalizer->normalize($message); + $result = $this->normalizer->normalize($message); $this->assertSame(1, $result['id']); $this->assertSame('uuid-123', $result['unique_id']); @@ -84,7 +91,6 @@ public function testNormalizeReturnsExpectedArray(): void public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void { - $normalizer = new MessageNormalizer(); - $this->assertSame([], $normalizer->normalize(new \stdClass())); + $this->assertSame([], $this->normalizer->normalize(new \stdClass())); } } From 0d367efed133ba97f7e88ca370aa75b5e15e022c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 22 Apr 2025 21:08:53 +0400 Subject: [PATCH 42/62] ISSUE-345: template by id --- src/Controller/TemplateController.php | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index 0e5b2b0..3867ea4 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -5,10 +5,12 @@ namespace PhpList\RestBundle\Controller; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Model\Messaging\Template; use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Serializer\TemplateNormalizer; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -82,4 +84,50 @@ public function getTemplates(Request $request): JsonResponse return new JsonResponse($normalized, Response::HTTP_OK); } + + #[Route('/{templateId}', name: 'get_template', methods: ['GET'])] + #[OA\Get( + path: '/templates/{templateId}', + description: 'Returns template by id.', + summary: 'Gets a templateI by id.', + tags: ['templates'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'templateId', + description: 'template 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/Template') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getTemplate( + Request $request, + #[MapEntity(mapping: ['templateId' => 'id'])] Template $template + ): JsonResponse { + $this->requireAuthentication($request); + + return new JsonResponse($this->normalizer->normalize($template), Response::HTTP_OK); + } } From cb62975e6d2d44c29c6a4264dfd10b66b091b914 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 24 Apr 2025 11:53:42 +0400 Subject: [PATCH 43/62] ISSUE-345: template property naming --- src/OpenApi/SwaggerSchemasResponseEntity.php | 4 ++-- src/Serializer/TemplateNormalizer.php | 4 ++-- tests/Unit/Serializer/MessageNormalizerTest.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/OpenApi/SwaggerSchemasResponseEntity.php b/src/OpenApi/SwaggerSchemasResponseEntity.php index d4331a7..7afe4c2 100644 --- a/src/OpenApi/SwaggerSchemasResponseEntity.php +++ b/src/OpenApi/SwaggerSchemasResponseEntity.php @@ -87,8 +87,8 @@ properties: [ new OA\Property(property: 'id', type: 'integer', example: 1), new OA\Property(property: 'title', type: 'string', example: 'Newsletter'), - new OA\Property(property: 'template', type: 'string', example: 'Hello World!', nullable: true), - new OA\Property(property: 'template_text', type: 'string', nullable: true), + new OA\Property(property: 'content', type: 'string', example: 'Hello World!', nullable: true), + new OA\Property(property: 'text', type: 'string', nullable: true), new OA\Property(property: 'order', type: 'integer', nullable: true), new OA\Property( property: 'images', diff --git a/src/Serializer/TemplateNormalizer.php b/src/Serializer/TemplateNormalizer.php index dcf7e83..bdf29e4 100644 --- a/src/Serializer/TemplateNormalizer.php +++ b/src/Serializer/TemplateNormalizer.php @@ -26,8 +26,8 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'title' => $object->getTitle(), - 'template' => $object->getTemplate(), - 'template_text' => $object->getTemplateText(), + 'content' => $object->getContent(), + 'text' => $object->getText(), 'order' => $object->getListOrder(), 'images' => $object->getImages()->toArray() ? array_map(function (TemplateImage $image) { return $this->templateImageNormalizer->normalize($image); diff --git a/tests/Unit/Serializer/MessageNormalizerTest.php b/tests/Unit/Serializer/MessageNormalizerTest.php index 307975d..12c6b06 100644 --- a/tests/Unit/Serializer/MessageNormalizerTest.php +++ b/tests/Unit/Serializer/MessageNormalizerTest.php @@ -39,8 +39,8 @@ public function testNormalizeReturnsExpectedArray(): void $template = $this->createConfiguredMock(Template::class, [ 'getId' => 5, 'getTitle' => 'Test Template', - 'getTemplate' => 'Hello', - 'getTemplateText' => 'Hello', + 'getContent' => 'Hello', + 'getText' => 'Hello', 'getListOrder' => 1, ]); From 9badc8f9379b7d8d356ba47ad4ca9b9d8e608b39 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 24 Apr 2025 18:49:59 +0400 Subject: [PATCH 44/62] ISSUE-345: create template endpoint --- composer.json | 3 +- config/services.yml | 3 + config/services/managers.yml | 8 ++ config/services/validators.yml | 8 ++ src/Controller/TemplateController.php | 109 +++++++++++++++++++ src/Entity/Dto/ValidationContext.php | 27 +++++ src/Entity/Request/CreateTemplateRequest.php | 24 ++++ src/Service/Manager/TemplateImageManager.php | 104 ++++++++++++++++++ src/Service/Manager/TemplateManager.php | 88 +++++++++++++++ src/Validator/TemplateImageValidator.php | 72 ++++++++++++ src/Validator/TemplateLinkValidator.php | 62 +++++++++++ src/Validator/ValidatorInterface.php | 12 ++ 12 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 src/Entity/Dto/ValidationContext.php create mode 100644 src/Entity/Request/CreateTemplateRequest.php create mode 100644 src/Service/Manager/TemplateImageManager.php create mode 100644 src/Service/Manager/TemplateManager.php create mode 100644 src/Validator/TemplateImageValidator.php create mode 100644 src/Validator/TemplateLinkValidator.php create mode 100644 src/Validator/ValidatorInterface.php diff --git a/composer.json b/composer.json index 367bdf6..06c8ac9 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", - "zircote/swagger-php": "^4.11" + "zircote/swagger-php": "^4.11", + "ext-dom": "*" }, "require-dev": { "phpunit/phpunit": "^10.0", diff --git a/config/services.yml b/config/services.yml index 24cec05..4d668a3 100644 --- a/config/services.yml +++ b/config/services.yml @@ -21,3 +21,6 @@ services: PhpList\Core\Security\Authentication: autowire: true autoconfigure: true + + GuzzleHttp\ClientInterface: + class: GuzzleHttp\Client diff --git a/config/services/managers.yml b/config/services/managers.yml index 3665728..3024af2 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -23,3 +23,11 @@ services: PhpList\RestBundle\Service\Manager\MessageManager: autowire: true autoconfigure: true + + PhpList\RestBundle\Service\Manager\TemplateManager: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Manager\TemplateImageManager: + autowire: true + autoconfigure: true diff --git a/config/services/validators.yml b/config/services/validators.yml index 8794db5..26ca7be 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -18,3 +18,11 @@ services: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Validator\TemplateLinkValidator: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Validator\TemplateImageValidator: + autowire: true + autoconfigure: true diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index 3867ea4..4b0106c 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -9,7 +9,10 @@ use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; +use PhpList\RestBundle\Entity\Request\CreateTemplateRequest; use PhpList\RestBundle\Serializer\TemplateNormalizer; +use PhpList\RestBundle\Service\Manager\TemplateManager; +use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -29,15 +32,21 @@ class TemplateController extends AbstractController private TemplateRepository $templateRepository; private TemplateNormalizer $normalizer; + private RequestValidator $validator; + private TemplateManager $templateManager; public function __construct( Authentication $authentication, TemplateRepository $templateRepository, TemplateNormalizer $normalizer, + RequestValidator $validator, + TemplateManager $templateManager ) { $this->authentication = $authentication; $this->templateRepository = $templateRepository; $this->normalizer = $normalizer; + $this->validator = $validator; + $this->templateManager = $templateManager; } #[Route('', name: 'get_templates', methods: ['GET'])] @@ -130,4 +139,104 @@ public function getTemplate( return new JsonResponse($this->normalizer->normalize($template), Response::HTTP_OK); } + + #[Route('', name: 'create_template', methods: ['POST'])] + #[OA\Post( + path: '/templates', + description: 'Returns a JSON response of created template.', + summary: 'Create a new template.', + requestBody: new OA\RequestBody( + description: 'Pass session credentials', + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema( + required: ['title'], + properties: [ + new OA\Property( + property: 'title', + type: 'string', + example: 'Newsletter Template' + ), + new OA\Property( + property: 'content', + type: 'string', + example: '[CONTENT]' + ), + new OA\Property( + property: 'text', + type: 'string', + example: '[CONTENT]' + ), + new OA\Property( + property: 'file', + description: 'Optional file upload for HTML content', + type: 'string', + format: 'binary' + ), + new OA\Property( + property: 'check_links', + description: 'Check that all links have full URLs', + type: 'boolean', + example: true + ), + new OA\Property( + property: 'check_images', + description: 'Check that all images have full URLs', + type: 'boolean', + example: false + ), + new OA\Property( + property: 'check_external_images', + description: 'Check that all external images exist', + type: 'boolean', + example: true + ), + ], + type: 'object' + ) + ) + ), + tags: ['templates'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ) + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Template') + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function createTemplates(Request $request): JsonResponse + { + $this->requireAuthentication($request); + + /** @var CreateTemplateRequest $createTemplateRequest */ + $createTemplateRequest = $this->validator->validate($request, CreateTemplateRequest::class); + + return new JsonResponse($this->templateManager->create($createTemplateRequest), Response::HTTP_CREATED); + } } diff --git a/src/Entity/Dto/ValidationContext.php b/src/Entity/Dto/ValidationContext.php new file mode 100644 index 0000000..225e0d9 --- /dev/null +++ b/src/Entity/Dto/ValidationContext.php @@ -0,0 +1,27 @@ +options[$key] = $value; + + return $this; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->options[$key] ?? $default; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->options); + } +} diff --git a/src/Entity/Request/CreateTemplateRequest.php b/src/Entity/Request/CreateTemplateRequest.php new file mode 100644 index 0000000..2791a9c --- /dev/null +++ b/src/Entity/Request/CreateTemplateRequest.php @@ -0,0 +1,24 @@ + 'image/gif', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'jpe' => 'image/jpeg', + 'bmp' => 'image/bmp', + 'png' => 'image/png', + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'swf' => 'application/x-shockwave-flash', + ]; + + private TemplateImageRepository $templateImageRepository; + private EntityManagerInterface $entityManager; + + public function __construct( + TemplateImageRepository $templateImageRepository, + EntityManagerInterface $entityManager + ) { + $this->templateImageRepository = $templateImageRepository; + $this->entityManager = $entityManager; + } + + /** @return TemplateImage[] */ + public function createImagesFromImagePaths(array $imagePaths, Template $template): array + { + $templateImages = []; + foreach ($imagePaths as $path) { + $image = new TemplateImage(); + $image->setTemplate($template); + $image->setFilename($path); + $image->setMimeType($this->guessMimeType($path)); + $image->setData(null); + + $this->entityManager->persist($image); + $templateImages[] = $image; + } + + $this->entityManager->flush(); + + return $templateImages; + } + + private function guessMimeType(string $filename): string + { + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + return self::IMAGE_MIME_TYPES[$ext] ?? 'application/octet-stream'; + } + + public function extractAllImages(string $html): array + { + $fromRegex = array_keys( + $this->extractTemplateImagesFromContent($html) + ); + + $fromDom = $this->extractImagesFromHtml($html); + + return array_values(array_unique(array_merge($fromRegex, $fromDom))); + } + + private function extractTemplateImagesFromContent(string $content): array + { + $regexp = sprintf('/"([^"]+\.(%s))"/Ui', implode('|', array_keys(self::IMAGE_MIME_TYPES))); + preg_match_all($regexp, stripslashes($content), $images); + + return array_count_values($images[1]); + } + + private function extractImagesFromHtml(string $html): array + { + $dom = new DOMDocument(); + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + @$dom->loadHTML($html); + $images = []; + + foreach ($dom->getElementsByTagName('img') as $img) { + $src = $img->getAttribute('src'); + if ($src) { + $images[] = $src; + } + } + + return $images; + } + + public function delete(TemplateImage $templateImage): void + { + $this->templateImageRepository->remove($templateImage); + } +} diff --git a/src/Service/Manager/TemplateManager.php b/src/Service/Manager/TemplateManager.php new file mode 100644 index 0000000..5e85e12 --- /dev/null +++ b/src/Service/Manager/TemplateManager.php @@ -0,0 +1,88 @@ +templateRepository = $templateRepository; + $this->entityManager = $entityManager; + $this->templateImageManager = $templateImageManager; + $this->templateLinkValidator = $templateLinkValidator; + $this->templateImageValidator = $templateImageValidator; + } + + public function create(CreateTemplateRequest $request): Template + { + $template = (new Template($request->title)) + ->setContent($request->content) + ->setText($request->text); + + if ($request->file instanceof UploadedFile) { + $template->setContent(file_get_contents($request->file->getPathname())); + } + + $context = (new ValidationContext()) + ->set('checkLinks', $request->checkLinks) + ->set('checkImages', $request->checkImages) + ->set('checkExternalImages', $request->checkExternalImages); + + $this->templateLinkValidator->validate($template->getContent() ?? '', $context); + + $imageUrls = $this->templateImageManager->extractAllImages($template->getContent() ?? ''); + $this->templateImageValidator->validate($imageUrls, $context); + + $this->templateRepository->save($template); + + $this->templateImageManager->createImagesFromImagePaths($imageUrls, $template); + + return $template; + } + + public function update(UpdateSubscriberRequest $subscriberRequest): Subscriber + { + /** @var Subscriber $subscriber */ + $subscriber = $this->templateRepository->find($subscriberRequest->subscriberId); + + $subscriber->setEmail($subscriberRequest->email); + $subscriber->setConfirmed($subscriberRequest->confirmed); + $subscriber->setBlacklisted($subscriberRequest->blacklisted); + $subscriber->setHtmlEmail($subscriberRequest->htmlEmail); + $subscriber->setDisabled($subscriberRequest->disabled); + $subscriber->setExtraData($subscriberRequest->additionalData); + + $this->entityManager->flush(); + + return $subscriber; + } + + public function delete(Template $template): void + { + $this->templateRepository->remove($template); + } +} diff --git a/src/Validator/TemplateImageValidator.php b/src/Validator/TemplateImageValidator.php new file mode 100644 index 0000000..b89da9a --- /dev/null +++ b/src/Validator/TemplateImageValidator.php @@ -0,0 +1,72 @@ +get('checkImages', false); + $checkExist = $context?->get('checkExternalImages', false); + + $errors = array_merge( + $checkFull ? $this->validateFullUrls($value) : [], + $checkExist ? $this->validateExistence($value) : [] + ); + + if (!empty($errors)) { + throw new ValidatorException(implode("\n", $errors)); + } + } + + private function validateFullUrls(array $urls): array + { + $errors = []; + + foreach ($urls as $url) { + if (!preg_match('#^https?://#i', $url)) { + $errors[] = sprintf('Image "%s" is not a full URL.', $url); + } + } + + return $errors; + } + + private function validateExistence(array $urls): array + { + $errors = []; + + foreach ($urls as $url) { + if (!preg_match('#^https?://#i', $url)) { + continue; + } + + try { + $response = $this->httpClient->request('HEAD', $url); + if ($response->getStatusCode() !== 200) { + $errors[] = sprintf('Image "%s" does not exist (HTTP %s)', $url, $response->getStatusCode()); + } + } catch (Throwable $e) { + $errors[] = sprintf('Image "%s" could not be validated: %s', $url, $e->getMessage()); + } + } + + return $errors; + } +} diff --git a/src/Validator/TemplateLinkValidator.php b/src/Validator/TemplateLinkValidator.php new file mode 100644 index 0000000..78916bc --- /dev/null +++ b/src/Validator/TemplateLinkValidator.php @@ -0,0 +1,62 @@ +get('checkLinks', false)) { + return; + } + $links = $this->extractLinks($value); + $invalid = []; + + foreach ($links as $link) { + if (!preg_match('#^https?://#i', $link) && + !preg_match('#^mailto:#i', $link) && + !in_array(strtoupper($link), self::PLACEHOLDERS, true) + ) { + $invalid[] = $link; + } + } + + if (!empty($invalid)) { + throw new ValidatorException(sprintf( + 'Not full URLs: %s', + implode(', ', $invalid) + )); + } + } + + private function extractLinks(string $html): array + { + $dom = new DOMDocument(); + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + @$dom->loadHTML($html); + $links = []; + + foreach ($dom->getElementsByTagName('a') as $node) { + $href = $node->getAttribute('href'); + if ($href) { + $links[] = $href; + } + } + + return $links; + } +} diff --git a/src/Validator/ValidatorInterface.php b/src/Validator/ValidatorInterface.php new file mode 100644 index 0000000..9b496fa --- /dev/null +++ b/src/Validator/ValidatorInterface.php @@ -0,0 +1,12 @@ + Date: Thu, 24 Apr 2025 20:02:26 +0400 Subject: [PATCH 45/62] ISSUE-345: fix required/optional fields --- src/Controller/CampaignController.php | 20 ++++++++++--------- .../Request/CreateSubscriberListRequest.php | 10 +++------- src/Entity/Request/CreateTemplateRequest.php | 1 + .../Request/Message/MessageOptionsRequest.php | 1 + .../Message/MessageScheduleRequest.php | 10 ++++------ src/OpenApi/SwaggerSchemasRequestDto.php | 4 ++++ 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 8c57747..54ec5b0 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -151,13 +151,14 @@ public function getMessage( description: 'Create a new message.', required: true, content: new OA\JsonContent( + required: ['content', 'format', 'metadata', 'schedule', 'options'], properties: [ new OA\Property(property: 'template_id', type: 'integer', example: 1), - new OA\Property(property: 'message_content', ref: '#/components/schemas/MessageContentRequest'), - new OA\Property(property: 'message_format', ref: '#/components/schemas/MessageFormatRequest'), - new OA\Property(property: 'message_metadata', ref: '#/components/schemas/MessageMetadataRequest'), - new OA\Property(property: 'message_schedule', ref: '#/components/schemas/MessageScheduleRequest'), - new OA\Property(property: 'message_options', ref: '#/components/schemas/MessageOptionsRequest'), + new OA\Property(property: 'content', ref: '#/components/schemas/MessageContentRequest'), + new OA\Property(property: 'format', ref: '#/components/schemas/MessageFormatRequest'), + new OA\Property(property: 'metadata', ref: '#/components/schemas/MessageMetadataRequest'), + new OA\Property(property: 'schedule', ref: '#/components/schemas/MessageScheduleRequest'), + new OA\Property(property: 'options', ref: '#/components/schemas/MessageOptionsRequest'), ], type: 'object' ) @@ -212,12 +213,13 @@ public function createMessage(Request $request, MessageNormalizer $normalizer): description: 'Update message.', required: true, content: new OA\JsonContent( + required: ['content', 'format', 'schedule', 'options'], properties: [ new OA\Property(property: 'template_id', type: 'integer', example: 1), - new OA\Property(property: 'message_content', ref: '#/components/schemas/MessageContentRequest'), - new OA\Property(property: 'message_format', ref: '#/components/schemas/MessageFormatRequest'), - new OA\Property(property: 'message_schedule', ref: '#/components/schemas/MessageScheduleRequest'), - new OA\Property(property: 'message_options', ref: '#/components/schemas/MessageOptionsRequest'), + new OA\Property(property: 'content', ref: '#/components/schemas/MessageContentRequest'), + new OA\Property(property: 'format', ref: '#/components/schemas/MessageFormatRequest'), + new OA\Property(property: 'schedule', ref: '#/components/schemas/MessageScheduleRequest'), + new OA\Property(property: 'options', ref: '#/components/schemas/MessageOptionsRequest'), ], type: 'object' ) diff --git a/src/Entity/Request/CreateSubscriberListRequest.php b/src/Entity/Request/CreateSubscriberListRequest.php index 30988d6..395141c 100644 --- a/src/Entity/Request/CreateSubscriberListRequest.php +++ b/src/Entity/Request/CreateSubscriberListRequest.php @@ -12,13 +12,9 @@ class CreateSubscriberListRequest implements RequestInterface #[Assert\NotNull] public string $name; - #[Assert\NotBlank] - public bool $public; + public bool $public = false; - #[Assert\NotBlank] - public int $listPosition; + public ?int $listPosition = null; - #[Assert\NotBlank] - #[Assert\NotNull] - public string $description; + public ?string $description = null; } diff --git a/src/Entity/Request/CreateTemplateRequest.php b/src/Entity/Request/CreateTemplateRequest.php index 2791a9c..fccdf4f 100644 --- a/src/Entity/Request/CreateTemplateRequest.php +++ b/src/Entity/Request/CreateTemplateRequest.php @@ -10,6 +10,7 @@ class CreateTemplateRequest { #[Assert\NotBlank] + #[Assert\NotNull] public string $title; #[Assert\NotBlank] diff --git a/src/Entity/Request/Message/MessageOptionsRequest.php b/src/Entity/Request/Message/MessageOptionsRequest.php index 52b999c..cafd7c6 100644 --- a/src/Entity/Request/Message/MessageOptionsRequest.php +++ b/src/Entity/Request/Message/MessageOptionsRequest.php @@ -8,6 +8,7 @@ class MessageOptionsRequest implements RequestDtoInterface { + #[Assert\NotBlank] #[Assert\Email] public string $fromField; diff --git a/src/Entity/Request/Message/MessageScheduleRequest.php b/src/Entity/Request/Message/MessageScheduleRequest.php index a3f2c05..c29c9b8 100644 --- a/src/Entity/Request/Message/MessageScheduleRequest.php +++ b/src/Entity/Request/Message/MessageScheduleRequest.php @@ -8,17 +8,15 @@ class MessageScheduleRequest implements RequestDtoInterface { - #[Assert\NotBlank] - public int $repeatInterval; + public ?int $repeatInterval = null; #[Assert\DateTime] - public string $repeatUntil; + public ?string $repeatUntil = null; - #[Assert\NotBlank] - public int $requeueInterval; + public ?int $requeueInterval = null; #[Assert\DateTime] - public string $requeueUntil; + public ?string $requeueUntil = null; #[Assert\NotBlank] public string $embargo; diff --git a/src/OpenApi/SwaggerSchemasRequestDto.php b/src/OpenApi/SwaggerSchemasRequestDto.php index e3cfbdd..2104676 100644 --- a/src/OpenApi/SwaggerSchemasRequestDto.php +++ b/src/OpenApi/SwaggerSchemasRequestDto.php @@ -8,6 +8,7 @@ #[OA\Schema( schema: 'MessageContentRequest', + required: ['subject', 'text', 'text_message', 'footer'], properties: [ new OA\Property(property: 'subject', type: 'string', example: 'Campaign Subject'), new OA\Property(property: 'text', type: 'string', example: 'Full text content'), @@ -18,6 +19,7 @@ )] #[OA\Schema( schema: 'MessageFormatRequest', + required: ['html_formated', 'send_format', 'format_options'], properties: [ new OA\Property(property: 'html_formated', type: 'boolean', example: true), new OA\Property( @@ -44,6 +46,7 @@ enum: ['html', 'text', 'invite'], )] #[OA\Schema( schema: 'MessageScheduleRequest', + required: ['embargo'], properties: [ new OA\Property(property: 'embargo', type: 'string', format: 'date-time', example: '2025-04-17 09:00:00'), new OA\Property(property: 'repeat_interval', type: 'string', example: '24 hours'), @@ -65,6 +68,7 @@ enum: ['html', 'text', 'invite'], )] #[OA\Schema( schema: 'MessageOptionsRequest', + required: ['from_field'], properties: [ new OA\Property(property: 'from_field', type: 'string', example: 'info@example.com'), new OA\Property(property: 'to_field', type: 'string', example: 'subscriber@example.com'), From 4f67858c3a98c968b3a1a4bf8e14af43f8d9a215 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 26 Apr 2025 14:11:12 +0400 Subject: [PATCH 46/62] ISSUE-345: delete message endpoint --- src/Controller/CampaignController.php | 53 ++++++++++++++++++++++++++ src/Controller/ListController.php | 11 +----- src/OpenApi/SwaggerSchemasResponse.php | 12 ++++++ src/Service/Manager/MessageManager.php | 5 +++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 54ec5b0..f98573f 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -280,4 +280,57 @@ public function updateMessage( Response::HTTP_OK ); } + + #[Route('/{messageId}', name: 'delete_campaign', methods: ['DELETE'])] + #[OA\Delete( + path: '/campaigns/{messageId}', + description: 'Delete campaign/message by id.', + summary: 'Delete campaign by id.', + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + 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 deleteMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] Message $message + ): JsonResponse { + $this->requireAuthentication($request); + + $this->messageManager->delete($message); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } } diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 67d352a..3a4977c 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -218,16 +218,7 @@ public function getList( new OA\Response( response: 404, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'There is no session with that ID.' - ) - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ) ] )] diff --git a/src/OpenApi/SwaggerSchemasResponse.php b/src/OpenApi/SwaggerSchemasResponse.php index 6d53a49..4701ada 100644 --- a/src/OpenApi/SwaggerSchemasResponse.php +++ b/src/OpenApi/SwaggerSchemasResponse.php @@ -50,6 +50,18 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'NotFoundErrorResponse', + required: ['message'], + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'There is no entity with that ID.' + ), + ], + type: 'object' +)] class SwaggerSchemasResponse { } diff --git a/src/Service/Manager/MessageManager.php b/src/Service/Manager/MessageManager.php index ea7e76f..fa083c8 100644 --- a/src/Service/Manager/MessageManager.php +++ b/src/Service/Manager/MessageManager.php @@ -43,4 +43,9 @@ public function updateMessage( return $message; } + + public function delete(Message $message): void + { + $this->messageRepository->remove($message); + } } From 8f5bdd355f320e034ad1e5a434ad04c9ffea0091 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 26 Apr 2025 14:26:24 +0400 Subject: [PATCH 47/62] ISSUE-345: validate that template has placeholder --- config/services/validators.yml | 3 ++ src/Entity/Request/CreateTemplateRequest.php | 3 ++ src/Validator/ContainsPlaceholder.php | 14 +++++++++ .../ContainsPlaceholderValidator.php | 29 +++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 src/Validator/ContainsPlaceholder.php create mode 100644 src/Validator/ContainsPlaceholderValidator.php diff --git a/config/services/validators.yml b/config/services/validators.yml index 26ca7be..bc0c8a5 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -26,3 +26,6 @@ services: PhpList\RestBundle\Validator\TemplateImageValidator: autowire: true autoconfigure: true + + PhpList\RestBundle\Validator\ContainsPlaceholderValidator: + tags: ['validator.constraint_validator'] diff --git a/src/Entity/Request/CreateTemplateRequest.php b/src/Entity/Request/CreateTemplateRequest.php index fccdf4f..0b723ce 100644 --- a/src/Entity/Request/CreateTemplateRequest.php +++ b/src/Entity/Request/CreateTemplateRequest.php @@ -6,6 +6,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Validator\Constraints as Assert; +use PhpList\RestBundle\Validator as CustomAssert; class CreateTemplateRequest { @@ -14,8 +15,10 @@ class CreateTemplateRequest public string $title; #[Assert\NotBlank] + #[CustomAssert\ContainsPlaceholder] public string $content; + #[CustomAssert\ContainsPlaceholder] public ?string $text = null; public ?UploadedFile $file = null; diff --git a/src/Validator/ContainsPlaceholder.php b/src/Validator/ContainsPlaceholder.php new file mode 100644 index 0000000..d2ff3de --- /dev/null +++ b/src/Validator/ContainsPlaceholder.php @@ -0,0 +1,14 @@ +placeholder)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ placeholder }}', $constraint->placeholder) + ->addViolation(); + } + } +} From 57df92ea5a33334bd80299726ee82ce707af0102 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 26 Apr 2025 14:49:30 +0400 Subject: [PATCH 48/62] ISSUE-345: add tests --- .../Service/Builder/MessageBuilderTest.php | 147 ++++++++++++++++++ .../Service/Manager/MessageManagerTest.php | 62 ++++++++ 2 files changed, 209 insertions(+) create mode 100644 tests/Unit/Service/Builder/MessageBuilderTest.php diff --git a/tests/Unit/Service/Builder/MessageBuilderTest.php b/tests/Unit/Service/Builder/MessageBuilderTest.php new file mode 100644 index 0000000..7bea466 --- /dev/null +++ b/tests/Unit/Service/Builder/MessageBuilderTest.php @@ -0,0 +1,147 @@ +createMock(TemplateRepository::class); + $this->formatBuilder = $this->createMock(MessageFormatBuilder::class); + $this->scheduleBuilder = $this->createMock(MessageScheduleBuilder::class); + $this->contentBuilder = $this->createMock(MessageContentBuilder::class); + $this->optionsBuilder = $this->createMock(MessageOptionsBuilder::class); + + $this->builder = new MessageBuilder( + $templateRepository, + $this->formatBuilder, + $this->scheduleBuilder, + $this->contentBuilder, + $this->optionsBuilder + ); + } + + private function createRequest(): CreateMessageRequest + { + $request = new CreateMessageRequest(); + $request->format = new MessageFormatRequest(); + $request->schedule = new MessageScheduleRequest(); + $request->content = new MessageContentRequest(); + $request->metadata = new MessageMetadataRequest(); + $request->metadata->status = 'draft'; + $request->options = new MessageOptionsRequest(); + $request->templateId = 0; + + return $request; + } + + private function mockBuildFromDtoCalls(CreateMessageRequest $request): void + { + $this->formatBuilder->expects($this->once()) + ->method('buildFromDto') + ->with($request->format) + ->willReturn($this->createMock(Message\MessageFormat::class)); + + $this->scheduleBuilder->expects($this->once()) + ->method('buildFromDto') + ->with($request->schedule) + ->willReturn($this->createMock(Message\MessageSchedule::class)); + + $this->contentBuilder->expects($this->once()) + ->method('buildFromDto') + ->with($request->content) + ->willReturn($this->createMock(Message\MessageContent::class)); + + $this->optionsBuilder->expects($this->once()) + ->method('buildFromDto') + ->with($request->options) + ->willReturn($this->createMock(Message\MessageOptions::class)); + } + + public function testBuildsNewMessage(): void + { + $request = $this->createRequest(); + $admin = $this->createMock(Administrator::class); + $context = new MessageContext($admin); + + $this->mockBuildFromDtoCalls($request); + + $this->builder->buildFromRequest($request, $context); + } + + public function testThrowsExceptionOnInvalidRequest(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->builder->buildFromRequest( + $this->createMock(RequestInterface::class), + new MessageContext($this->createMock(Administrator::class)) + ); + } + + public function testThrowsExceptionOnInvalidContext(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->builder->buildFromRequest(new CreateMessageRequest(), new \stdClass()); + } + + public function testUpdatesExistingMessage(): void + { + $request = $this->createRequest(); + $admin = $this->createMock(Administrator::class); + $existingMessage = $this->createMock(Message::class); + $context = new MessageContext($admin, $existingMessage); + + $this->mockBuildFromDtoCalls($request); + + $existingMessage + ->expects($this->once()) + ->method('setFormat') + ->with($this->isInstanceOf(Message\MessageFormat::class)); + $existingMessage + ->expects($this->once()) + ->method('setSchedule') + ->with($this->isInstanceOf(Message\MessageSchedule::class)); + $existingMessage + ->expects($this->once()) + ->method('setContent') + ->with($this->isInstanceOf(Message\MessageContent::class)); + $existingMessage + ->expects($this->once()) + ->method('setOptions') + ->with($this->isInstanceOf(Message\MessageOptions::class)); + $existingMessage->expects($this->once())->method('setTemplate')->with(null); + + $result = $this->builder->buildFromRequest($request, $context); + + $this->assertSame($existingMessage, $result); + } +} diff --git a/tests/Unit/Service/Manager/MessageManagerTest.php b/tests/Unit/Service/Manager/MessageManagerTest.php index 99dbfe4..6b08731 100644 --- a/tests/Unit/Service/Manager/MessageManagerTest.php +++ b/tests/Unit/Service/Manager/MessageManagerTest.php @@ -87,4 +87,66 @@ public function testCreateMessageReturnsPersistedMessage(): void $this->assertSame('Subject', $message->getContent()->getSubject()); $this->assertSame('draft', $message->getMetadata()->getStatus()); } + + public function testUpdateMessageReturnsUpdatedMessage(): void + { + $messageRepository = $this->createMock(MessageRepository::class); + $messageBuilder = $this->createMock(MessageBuilder::class); + + $manager = new MessageManager($messageRepository, $messageBuilder); + + $updateRequest = new \PhpList\RestBundle\Entity\Request\UpdateMessageRequest(); + $updateRequest->messageId = 1; + $updateRequest->format = new MessageFormatRequest(); + $updateRequest->format->htmlFormated = false; + $updateRequest->format->sendFormat = 'text'; + $updateRequest->format->formatOptions = ['text']; + + $updateRequest->schedule = new MessageScheduleRequest(); + $updateRequest->schedule->repeatInterval = 0; + $updateRequest->schedule->repeatUntil = '2025-04-30T00:00:00+00:00'; + $updateRequest->schedule->requeueInterval = 0; + $updateRequest->schedule->requeueUntil = '2025-04-20T00:00:00+00:00'; + $updateRequest->schedule->embargo = '2025-04-17T09:00:00+00:00'; + + $updateRequest->content = new MessageContentRequest(); + $updateRequest->content->subject = 'Updated Subject'; + $updateRequest->content->text = 'Updated Full text'; + $updateRequest->content->textMessage = 'Updated Short text'; + $updateRequest->content->footer = 'Updated Footer'; + + $updateRequest->options = new MessageOptionsRequest(); + $updateRequest->options->fromField = 'newfrom@example.com'; + $updateRequest->options->toField = 'newto@example.com'; + $updateRequest->options->replyTo = 'newreply@example.com'; + $updateRequest->options->userSelection = 'active-users'; + + $updateRequest->templateId = 2; + + $authUser = $this->createMock(Administrator::class); + + $existingMessage = $this->createMock(Message::class); + $expectedContent = $this->createMock(Message\MessageContent::class); + $expectedMetadata = $this->createMock(Message\MessageMetadata::class); + + $expectedContent->method('getSubject')->willReturn('Updated Subject'); + $expectedMetadata->method('getStatus')->willReturn('draft'); + + $existingMessage->method('getContent')->willReturn($expectedContent); + $existingMessage->method('getMetadata')->willReturn($expectedMetadata); + + $messageBuilder->expects($this->once()) + ->method('buildFromRequest') + ->with($updateRequest, $this->anything()) + ->willReturn($existingMessage); + + $messageRepository->expects($this->once()) + ->method('save') + ->with($existingMessage); + + $message = $manager->updateMessage($updateRequest, $existingMessage, $authUser); + + $this->assertSame('Updated Subject', $message->getContent()->getSubject()); + $this->assertSame('draft', $message->getMetadata()->getStatus()); + } } From 6212beb270a02c733ab0746821ae3bdc6a054397 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 26 Apr 2025 14:59:15 +0400 Subject: [PATCH 49/62] ISSUE-345: use Constraint directory --- config/services/validators.yml | 8 ++++---- src/Entity/Request/CreateMessageRequest.php | 2 +- src/Entity/Request/CreateSubscriberRequest.php | 2 +- src/Entity/Request/CreateTemplateRequest.php | 2 +- src/Entity/Request/SubscriptionRequest.php | 2 +- src/Entity/Request/UpdateMessageRequest.php | 2 +- src/Entity/Request/UpdateSubscriberRequest.php | 2 +- src/Validator/{ => Constraint}/ContainsPlaceholder.php | 2 +- .../{ => Constraint}/ContainsPlaceholderValidator.php | 2 +- src/Validator/{ => Constraint}/EmailExists.php | 2 +- src/Validator/{ => Constraint}/EmailExistsValidator.php | 2 +- src/Validator/{ => Constraint}/TemplateExists.php | 2 +- .../{ => Constraint}/TemplateExistsValidator.php | 2 +- src/Validator/{ => Constraint}/UniqueEmail.php | 2 +- src/Validator/{ => Constraint}/UniqueEmailValidator.php | 2 +- .../{ => Constraint}/EmailExistsValidatorTest.php | 8 ++++---- .../{ => Constraint}/TemplateExistsValidatorTest.php | 8 ++++---- .../{ => Constraint}/UniqueEmailValidatorTest.php | 6 +++--- 18 files changed, 29 insertions(+), 29 deletions(-) rename src/Validator/{ => Constraint}/ContainsPlaceholder.php (88%) rename src/Validator/{ => Constraint}/ContainsPlaceholderValidator.php (94%) rename src/Validator/{ => Constraint}/EmailExists.php (92%) rename src/Validator/{ => Constraint}/EmailExistsValidator.php (96%) rename src/Validator/{ => Constraint}/TemplateExists.php (92%) rename src/Validator/{ => Constraint}/TemplateExistsValidator.php (96%) rename src/Validator/{ => Constraint}/UniqueEmail.php (92%) rename src/Validator/{ => Constraint}/UniqueEmailValidator.php (96%) rename tests/Unit/Validator/{ => Constraint}/EmailExistsValidatorTest.php (94%) rename tests/Unit/Validator/{ => Constraint}/TemplateExistsValidatorTest.php (93%) rename tests/Unit/Validator/{ => Constraint}/UniqueEmailValidatorTest.php (95%) diff --git a/config/services/validators.yml b/config/services/validators.yml index bc0c8a5..481556c 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -4,17 +4,17 @@ services: autoconfigure: true public: false - PhpList\RestBundle\Validator\UniqueEmailValidator: + PhpList\RestBundle\Validator\Constraint\UniqueEmailValidator: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] - PhpList\RestBundle\Validator\EmailExistsValidator: + PhpList\RestBundle\Validator\Constraint\EmailExistsValidator: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] - PhpList\RestBundle\Validator\TemplateExistsValidator: + PhpList\RestBundle\Validator\Constraint\TemplateExistsValidator: autowire: true autoconfigure: true tags: [ 'validator.constraint_validator' ] @@ -27,5 +27,5 @@ services: autowire: true autoconfigure: true - PhpList\RestBundle\Validator\ContainsPlaceholderValidator: + PhpList\RestBundle\Validator\Constraint\ContainsPlaceholderValidator: tags: ['validator.constraint_validator'] diff --git a/src/Entity/Request/CreateMessageRequest.php b/src/Entity/Request/CreateMessageRequest.php index 80b4cef..0aaf51c 100644 --- a/src/Entity/Request/CreateMessageRequest.php +++ b/src/Entity/Request/CreateMessageRequest.php @@ -9,8 +9,8 @@ use PhpList\RestBundle\Entity\Request\Message\MessageMetadataRequest; use PhpList\RestBundle\Entity\Request\Message\MessageOptionsRequest; use PhpList\RestBundle\Entity\Request\Message\MessageScheduleRequest; +use PhpList\RestBundle\Validator\Constraint as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; -use PhpList\RestBundle\Validator as CustomAssert; class CreateMessageRequest implements RequestInterface { diff --git a/src/Entity/Request/CreateSubscriberRequest.php b/src/Entity/Request/CreateSubscriberRequest.php index 74bd0bf..f3317f8 100644 --- a/src/Entity/Request/CreateSubscriberRequest.php +++ b/src/Entity/Request/CreateSubscriberRequest.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Entity\Request; -use PhpList\RestBundle\Validator as CustomAssert; +use PhpList\RestBundle\Validator\Constraint as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; class CreateSubscriberRequest implements RequestInterface diff --git a/src/Entity/Request/CreateTemplateRequest.php b/src/Entity/Request/CreateTemplateRequest.php index 0b723ce..010c1ee 100644 --- a/src/Entity/Request/CreateTemplateRequest.php +++ b/src/Entity/Request/CreateTemplateRequest.php @@ -4,9 +4,9 @@ namespace PhpList\RestBundle\Entity\Request; +use PhpList\RestBundle\Validator\Constraint as CustomAssert; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Validator\Constraints as Assert; -use PhpList\RestBundle\Validator as CustomAssert; class CreateTemplateRequest { diff --git a/src/Entity/Request/SubscriptionRequest.php b/src/Entity/Request/SubscriptionRequest.php index 81aebf0..c31e087 100644 --- a/src/Entity/Request/SubscriptionRequest.php +++ b/src/Entity/Request/SubscriptionRequest.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Entity\Request; -use PhpList\RestBundle\Validator\EmailExists; +use PhpList\RestBundle\Validator\Constraint\EmailExists; use Symfony\Component\Validator\Constraints as Assert; class SubscriptionRequest implements RequestInterface diff --git a/src/Entity/Request/UpdateMessageRequest.php b/src/Entity/Request/UpdateMessageRequest.php index 83cea4e..66539dd 100644 --- a/src/Entity/Request/UpdateMessageRequest.php +++ b/src/Entity/Request/UpdateMessageRequest.php @@ -8,8 +8,8 @@ use PhpList\RestBundle\Entity\Request\Message\MessageFormatRequest; use PhpList\RestBundle\Entity\Request\Message\MessageOptionsRequest; use PhpList\RestBundle\Entity\Request\Message\MessageScheduleRequest; +use PhpList\RestBundle\Validator\Constraint as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; -use PhpList\RestBundle\Validator as CustomAssert; class UpdateMessageRequest extends CreateMessageRequest { diff --git a/src/Entity/Request/UpdateSubscriberRequest.php b/src/Entity/Request/UpdateSubscriberRequest.php index 6ee254e..5902a13 100644 --- a/src/Entity/Request/UpdateSubscriberRequest.php +++ b/src/Entity/Request/UpdateSubscriberRequest.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Entity\Request; -use PhpList\RestBundle\Validator as CustomAssert; +use PhpList\RestBundle\Validator\Constraint as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; class UpdateSubscriberRequest implements RequestInterface diff --git a/src/Validator/ContainsPlaceholder.php b/src/Validator/Constraint/ContainsPlaceholder.php similarity index 88% rename from src/Validator/ContainsPlaceholder.php rename to src/Validator/Constraint/ContainsPlaceholder.php index d2ff3de..f6179ed 100644 --- a/src/Validator/ContainsPlaceholder.php +++ b/src/Validator/Constraint/ContainsPlaceholder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use Symfony\Component\Validator\Constraint; diff --git a/src/Validator/ContainsPlaceholderValidator.php b/src/Validator/Constraint/ContainsPlaceholderValidator.php similarity index 94% rename from src/Validator/ContainsPlaceholderValidator.php rename to src/Validator/Constraint/ContainsPlaceholderValidator.php index f0e9862..75877f6 100644 --- a/src/Validator/ContainsPlaceholderValidator.php +++ b/src/Validator/Constraint/ContainsPlaceholderValidator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use InvalidArgumentException; use Symfony\Component\Validator\Constraint; diff --git a/src/Validator/EmailExists.php b/src/Validator/Constraint/EmailExists.php similarity index 92% rename from src/Validator/EmailExists.php rename to src/Validator/Constraint/EmailExists.php index 03a6a9e..48d0045 100644 --- a/src/Validator/EmailExists.php +++ b/src/Validator/Constraint/EmailExists.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use Symfony\Component\Validator\Constraint; diff --git a/src/Validator/EmailExistsValidator.php b/src/Validator/Constraint/EmailExistsValidator.php similarity index 96% rename from src/Validator/EmailExistsValidator.php rename to src/Validator/Constraint/EmailExistsValidator.php index d971dc6..9c4f5b5 100644 --- a/src/Validator/EmailExistsValidator.php +++ b/src/Validator/Constraint/EmailExistsValidator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; diff --git a/src/Validator/TemplateExists.php b/src/Validator/Constraint/TemplateExists.php similarity index 92% rename from src/Validator/TemplateExists.php rename to src/Validator/Constraint/TemplateExists.php index 7663ceb..b46b395 100644 --- a/src/Validator/TemplateExists.php +++ b/src/Validator/Constraint/TemplateExists.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use Symfony\Component\Validator\Constraint; diff --git a/src/Validator/TemplateExistsValidator.php b/src/Validator/Constraint/TemplateExistsValidator.php similarity index 96% rename from src/Validator/TemplateExistsValidator.php rename to src/Validator/Constraint/TemplateExistsValidator.php index 319852c..0cfdc08 100644 --- a/src/Validator/TemplateExistsValidator.php +++ b/src/Validator/Constraint/TemplateExistsValidator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; diff --git a/src/Validator/UniqueEmail.php b/src/Validator/Constraint/UniqueEmail.php similarity index 92% rename from src/Validator/UniqueEmail.php rename to src/Validator/Constraint/UniqueEmail.php index 7a14b1a..e3f6aa0 100644 --- a/src/Validator/UniqueEmail.php +++ b/src/Validator/Constraint/UniqueEmail.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use Symfony\Component\Validator\Constraint; diff --git a/src/Validator/UniqueEmailValidator.php b/src/Validator/Constraint/UniqueEmailValidator.php similarity index 96% rename from src/Validator/UniqueEmailValidator.php rename to src/Validator/Constraint/UniqueEmailValidator.php index 63f68dc..2b5ab3d 100644 --- a/src/Validator/UniqueEmailValidator.php +++ b/src/Validator/Constraint/UniqueEmailValidator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Validator; +namespace PhpList\RestBundle\Validator\Constraint; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; diff --git a/tests/Unit/Validator/EmailExistsValidatorTest.php b/tests/Unit/Validator/Constraint/EmailExistsValidatorTest.php similarity index 94% rename from tests/Unit/Validator/EmailExistsValidatorTest.php rename to tests/Unit/Validator/Constraint/EmailExistsValidatorTest.php index 995efbc..c989b79 100644 --- a/tests/Unit/Validator/EmailExistsValidatorTest.php +++ b/tests/Unit/Validator/Constraint/EmailExistsValidatorTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Unit\Validator; +namespace PhpList\RestBundle\Tests\Unit\Validator\Constraint; use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\RestBundle\Validator\EmailExists; -use PhpList\RestBundle\Validator\EmailExistsValidator; +use PhpList\RestBundle\Validator\Constraint\EmailExists; +use PhpList\RestBundle\Validator\Constraint\EmailExistsValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedValueException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class EmailExistsValidatorTest extends TestCase { diff --git a/tests/Unit/Validator/TemplateExistsValidatorTest.php b/tests/Unit/Validator/Constraint/TemplateExistsValidatorTest.php similarity index 93% rename from tests/Unit/Validator/TemplateExistsValidatorTest.php rename to tests/Unit/Validator/Constraint/TemplateExistsValidatorTest.php index 38f6304..04e478d 100644 --- a/tests/Unit/Validator/TemplateExistsValidatorTest.php +++ b/tests/Unit/Validator/Constraint/TemplateExistsValidatorTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Unit\Validator; +namespace PhpList\RestBundle\Tests\Unit\Validator\Constraint; use PhpList\Core\Domain\Model\Messaging\Template; use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; -use PhpList\RestBundle\Validator\TemplateExists; -use PhpList\RestBundle\Validator\TemplateExistsValidator; +use PhpList\RestBundle\Validator\Constraint\TemplateExists; +use PhpList\RestBundle\Validator\Constraint\TemplateExistsValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedValueException; -use Symfony\Component\HttpKernel\Exception\ConflictHttpException; class TemplateExistsValidatorTest extends TestCase { diff --git a/tests/Unit/Validator/UniqueEmailValidatorTest.php b/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php similarity index 95% rename from tests/Unit/Validator/UniqueEmailValidatorTest.php rename to tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php index 6ec2f1d..985c770 100644 --- a/tests/Unit/Validator/UniqueEmailValidatorTest.php +++ b/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\Unit\Validator; +namespace PhpList\RestBundle\Tests\Unit\Validator\Constraint; use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use PhpList\RestBundle\Validator\UniqueEmail; -use PhpList\RestBundle\Validator\UniqueEmailValidator; +use PhpList\RestBundle\Validator\Constraint\UniqueEmail; +use PhpList\RestBundle\Validator\Constraint\UniqueEmailValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; From 68465fc009a2e0f8bdcbad22a7278a72fd3456ff Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 26 Apr 2025 15:09:40 +0400 Subject: [PATCH 50/62] ISSUE-345: add tests --- src/Controller/TemplateController.php | 5 +- src/Entity/Request/CreateTemplateRequest.php | 2 +- .../Controller/TemplateControllerTest.php | 118 ++++++++++++++++++ .../TemplateImageNormalizerTest.php | 62 +++++++++ .../Serializer/TemplateNormalizerTest.php | 102 +++++++++++++++ .../Service/Builder/MessageBuilderTest.php | 9 +- .../Builder/MessageContentBuilderTest.php | 44 +++++++ .../Builder/MessageFormatBuilderTest.php | 42 +++++++ .../Builder/MessageOptionsBuilderTest.php | 44 +++++++ .../Builder/MessageScheduleBuilderTest.php | 47 +++++++ .../Manager/TemplateImageManagerTest.php | 88 +++++++++++++ .../Validator/TemplateImageValidatorTest.php | 86 +++++++++++++ .../Validator/TemplateLinkValidatorTest.php | 66 ++++++++++ 13 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Controller/TemplateControllerTest.php create mode 100644 tests/Unit/Serializer/TemplateImageNormalizerTest.php create mode 100644 tests/Unit/Serializer/TemplateNormalizerTest.php create mode 100644 tests/Unit/Service/Builder/MessageContentBuilderTest.php create mode 100644 tests/Unit/Service/Builder/MessageFormatBuilderTest.php create mode 100644 tests/Unit/Service/Builder/MessageOptionsBuilderTest.php create mode 100644 tests/Unit/Service/Builder/MessageScheduleBuilderTest.php create mode 100644 tests/Unit/Service/Manager/TemplateImageManagerTest.php create mode 100644 tests/Unit/Validator/TemplateImageValidatorTest.php create mode 100644 tests/Unit/Validator/TemplateLinkValidatorTest.php diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index 4b0106c..c98e814 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -237,6 +237,9 @@ public function createTemplates(Request $request): JsonResponse /** @var CreateTemplateRequest $createTemplateRequest */ $createTemplateRequest = $this->validator->validate($request, CreateTemplateRequest::class); - return new JsonResponse($this->templateManager->create($createTemplateRequest), Response::HTTP_CREATED); + return new JsonResponse( + $this->normalizer->normalize($this->templateManager->create($createTemplateRequest)), + Response::HTTP_CREATED + ); } } diff --git a/src/Entity/Request/CreateTemplateRequest.php b/src/Entity/Request/CreateTemplateRequest.php index 010c1ee..3ba90bf 100644 --- a/src/Entity/Request/CreateTemplateRequest.php +++ b/src/Entity/Request/CreateTemplateRequest.php @@ -8,7 +8,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Validator\Constraints as Assert; -class CreateTemplateRequest +class CreateTemplateRequest implements RequestInterface { #[Assert\NotBlank] #[Assert\NotNull] diff --git a/tests/Unit/Controller/TemplateControllerTest.php b/tests/Unit/Controller/TemplateControllerTest.php new file mode 100644 index 0000000..a116575 --- /dev/null +++ b/tests/Unit/Controller/TemplateControllerTest.php @@ -0,0 +1,118 @@ +createMock(Authentication::class); + $authentication->method('authenticateByApiKey')->willReturn(new Administrator()); + $this->templateRepository = $this->createMock(TemplateRepository::class); + $this->normalizer = $this->createMock(TemplateNormalizer::class); + $this->validator = $this->createMock(RequestValidator::class); + $this->templateManager = $this->createMock(TemplateManager::class); + + $this->controller = new TemplateController( + $authentication, + $this->templateRepository, + $this->normalizer, + $this->validator, + $this->templateManager + ); + } + + public function testGetTemplatesReturnsTemplates(): void + { + $request = $this->createMock(Request::class); + + $template = $this->createMock(Template::class); + + $this->templateRepository->expects($this->once()) + ->method('findAll') + ->willReturn([$template]); + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($template) + ->willReturn(['id' => 1, 'title' => 'Test Template']); + + $response = $this->controller->getTemplates($request); + + $this->assertSame(200, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertEquals([['id' => 1, 'title' => 'Test Template']], $data); + } + + public function testGetTemplateReturnsSingleTemplate(): void + { + $request = $this->createMock(Request::class); + + $template = $this->createMock(Template::class); + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($template) + ->willReturn(['id' => 1, 'title' => 'Single Template']); + + $response = $this->controller->getTemplate($request, $template); + + $this->assertSame(200, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertEquals(['id' => 1, 'title' => 'Single Template'], $data); + } + + public function testCreateTemplateReturnsCreatedTemplate(): void + { + $request = $this->createMock(Request::class); + + $createTemplateRequest = $this->createMock(CreateTemplateRequest::class); + + $this->validator->expects($this->once()) + ->method('validate') + ->with($request, CreateTemplateRequest::class) + ->willReturn($createTemplateRequest); + + $template = $this->createMock(Template::class); + + $this->templateManager->expects($this->once()) + ->method('create') + ->with($createTemplateRequest) + ->willReturn($template); + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($template) + ->willReturn(['id' => 1, 'title' => 'Created Template']); + + $response = $this->controller->createTemplates($request); + + $this->assertSame(201, $response->getStatusCode()); + + $data = json_decode($response->getContent(), true); + $this->assertEquals(['id' => 1, 'title' => 'Created Template'], $data); + } +} diff --git a/tests/Unit/Serializer/TemplateImageNormalizerTest.php b/tests/Unit/Serializer/TemplateImageNormalizerTest.php new file mode 100644 index 0000000..3a3a7dd --- /dev/null +++ b/tests/Unit/Serializer/TemplateImageNormalizerTest.php @@ -0,0 +1,62 @@ +normalizer = new TemplateImageNormalizer(); + } + + public function testSupportsNormalizationOnlyForTemplateImage(): void + { + $this->assertTrue($this->normalizer->supportsNormalization($this->createMock(TemplateImage::class))); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeTemplateImage(): void + { + $template = $this->createMock(Template::class); + $template->method('getId')->willReturn(42); + + $templateImage = $this->createMock(TemplateImage::class); + $templateImage->method('getId')->willReturn(10); + $templateImage->method('getTemplate')->willReturn($template); + $templateImage->method('getMimeType')->willReturn('image/png'); + $templateImage->method('getFilename')->willReturn('test.png'); + $templateImage->method('getData')->willReturn('binary-data'); + $templateImage->method('getWidth')->willReturn(100); + $templateImage->method('getHeight')->willReturn(200); + + $normalized = $this->normalizer->normalize($templateImage); + + $this->assertIsArray($normalized); + $this->assertEquals([ + 'id' => 10, + 'template_id' => 42, + 'mimetype' => 'image/png', + 'filename' => 'test.png', + 'data' => base64_encode('binary-data'), + 'width' => 100, + 'height' => 200, + ], $normalized); + } + + public function testNormalizeReturnsEmptyArrayForInvalidObject(): void + { + $normalized = $this->normalizer->normalize(new \stdClass()); + + $this->assertIsArray($normalized); + $this->assertEmpty($normalized); + } +} diff --git a/tests/Unit/Serializer/TemplateNormalizerTest.php b/tests/Unit/Serializer/TemplateNormalizerTest.php new file mode 100644 index 0000000..23bac70 --- /dev/null +++ b/tests/Unit/Serializer/TemplateNormalizerTest.php @@ -0,0 +1,102 @@ +templateImageNormalizer = $this->createMock(TemplateImageNormalizer::class); + $this->normalizer = new TemplateNormalizer($this->templateImageNormalizer); + } + + public function testSupportsNormalizationOnlyForTemplate(): void + { + $this->assertTrue($this->normalizer->supportsNormalization($this->createMock(Template::class))); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeTemplateWithImages(): void + { + $template = $this->createMock(Template::class); + $template->method('getId')->willReturn(1); + $template->method('getTitle')->willReturn('Test Template'); + $template->method('getContent')->willReturn('Content'); + $template->method('getText')->willReturn('Plain text'); + $template->method('getListOrder')->willReturn(5); + + $image = $this->createMock(TemplateImage::class); + + $template->method('getImages')->willReturn(new ArrayCollection([$image])); + + $this->templateImageNormalizer->expects($this->once()) + ->method('normalize') + ->with($image) + ->willReturn([ + 'id' => 100, + 'filename' => 'test.png' + ]); + + $normalized = $this->normalizer->normalize($template); + + $this->assertIsArray($normalized); + $this->assertEquals([ + 'id' => 1, + 'title' => 'Test Template', + 'content' => 'Content', + 'text' => 'Plain text', + 'order' => 5, + 'images' => [ + [ + 'id' => 100, + 'filename' => 'test.png' + ] + ] + ], $normalized); + } + + public function testNormalizeTemplateWithoutImages(): void + { + $template = $this->createMock(Template::class); + $template->method('getId')->willReturn(2); + $template->method('getTitle')->willReturn('Empty Template'); + $template->method('getContent')->willReturn('No Images'); + $template->method('getText')->willReturn('No images text'); + $template->method('getListOrder')->willReturn(0); + + $template->method('getImages')->willReturn(new ArrayCollection([])); + + $normalized = $this->normalizer->normalize($template); + + $this->assertIsArray($normalized); + $this->assertEquals([ + 'id' => 2, + 'title' => 'Empty Template', + 'content' => 'No Images', + 'text' => 'No images text', + 'order' => 0, + 'images' => null + ], $normalized); + } + + public function testNormalizeReturnsEmptyArrayForInvalidObject(): void + { + $normalized = $this->normalizer->normalize(new \stdClass()); + + $this->assertIsArray($normalized); + $this->assertEmpty($normalized); + } +} diff --git a/tests/Unit/Service/Builder/MessageBuilderTest.php b/tests/Unit/Service/Builder/MessageBuilderTest.php index 7bea466..ba2ead4 100644 --- a/tests/Unit/Service/Builder/MessageBuilderTest.php +++ b/tests/Unit/Service/Builder/MessageBuilderTest.php @@ -21,14 +21,15 @@ use PhpList\RestBundle\Service\Builder\MessageFormatBuilder; use PhpList\RestBundle\Service\Builder\MessageOptionsBuilder; use PhpList\RestBundle\Service\Builder\MessageScheduleBuilder; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class MessageBuilderTest extends TestCase { - private MessageFormatBuilder $formatBuilder; - private MessageScheduleBuilder $scheduleBuilder; - private MessageContentBuilder $contentBuilder; - private MessageOptionsBuilder $optionsBuilder; + private MessageFormatBuilder&MockObject $formatBuilder; + private MessageScheduleBuilder&MockObject $scheduleBuilder; + private MessageContentBuilder&MockObject $contentBuilder; + private MessageOptionsBuilder&MockObject $optionsBuilder; private MessageBuilder $builder; protected function setUp(): void diff --git a/tests/Unit/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Service/Builder/MessageContentBuilderTest.php new file mode 100644 index 0000000..7a45d4c --- /dev/null +++ b/tests/Unit/Service/Builder/MessageContentBuilderTest.php @@ -0,0 +1,44 @@ +builder = new MessageContentBuilder(); + } + + public function testBuildsMessageContentSuccessfully(): void + { + $dto = new MessageContentRequest(); + $dto->subject = 'Test Subject'; + $dto->text = 'Full text content'; + $dto->textMessage = 'Short text version'; + $dto->footer = 'Footer text'; + + $messageContent = $this->builder->buildFromDto($dto); + + $this->assertSame('Test Subject', $messageContent->getSubject()); + $this->assertSame('Full text content', $messageContent->getText()); + $this->assertSame('Short text version', $messageContent->getTextMessage()); + $this->assertSame('Footer text', $messageContent->getFooter()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->buildFromDto($invalidDto); + } +} diff --git a/tests/Unit/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Service/Builder/MessageFormatBuilderTest.php new file mode 100644 index 0000000..a561248 --- /dev/null +++ b/tests/Unit/Service/Builder/MessageFormatBuilderTest.php @@ -0,0 +1,42 @@ +builder = new MessageFormatBuilder(); + } + + public function testBuildsMessageFormatSuccessfully(): void + { + $dto = new MessageFormatRequest(); + $dto->htmlFormated = true; + $dto->sendFormat = 'html'; + $dto->formatOptions = ['html', 'text']; + + $messageFormat = $this->builder->buildFromDto($dto); + + $this->assertSame(true, $messageFormat->isHtmlFormatted()); + $this->assertSame('html', $messageFormat->getSendFormat()); + $this->assertEqualsCanonicalizing(['html', 'text'], $messageFormat->getFormatOptions()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->buildFromDto($invalidDto); + } +} diff --git a/tests/Unit/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Service/Builder/MessageOptionsBuilderTest.php new file mode 100644 index 0000000..30fe00c --- /dev/null +++ b/tests/Unit/Service/Builder/MessageOptionsBuilderTest.php @@ -0,0 +1,44 @@ +builder = new MessageOptionsBuilder(); + } + + public function testBuildsMessageOptionsSuccessfully(): void + { + $dto = new MessageOptionsRequest(); + $dto->fromField = 'info@example.com'; + $dto->toField = 'user@example.com'; + $dto->replyTo = 'reply@example.com'; + $dto->userSelection = 'all-users'; + + $messageOptions = $this->builder->buildFromDto($dto); + + $this->assertSame('info@example.com', $messageOptions->getFromField()); + $this->assertSame('user@example.com', $messageOptions->getToField()); + $this->assertSame('reply@example.com', $messageOptions->getReplyTo()); + $this->assertSame('all-users', $messageOptions->getUserSelection()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->buildFromDto($invalidDto); + } +} diff --git a/tests/Unit/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Service/Builder/MessageScheduleBuilderTest.php new file mode 100644 index 0000000..85078c4 --- /dev/null +++ b/tests/Unit/Service/Builder/MessageScheduleBuilderTest.php @@ -0,0 +1,47 @@ +builder = new MessageScheduleBuilder(); + } + + public function testBuildsMessageScheduleSuccessfully(): void + { + $dto = new MessageScheduleRequest(); + $dto->repeatInterval = 1440; + $dto->repeatUntil = '2025-04-30T00:00:00+00:00'; + $dto->requeueInterval = 720; + $dto->requeueUntil = '2025-04-20T00:00:00+00:00'; + $dto->embargo = '2025-04-17T09:00:00+00:00'; + + $messageSchedule = $this->builder->buildFromDto($dto); + + $this->assertSame(1440, $messageSchedule->getRepeatInterval()); + $this->assertEquals(new DateTime('2025-04-30T00:00:00+00:00'), $messageSchedule->getRepeatUntil()); + $this->assertSame(720, $messageSchedule->getRequeueInterval()); + $this->assertEquals(new DateTime('2025-04-20T00:00:00+00:00'), $messageSchedule->getRequeueUntil()); + $this->assertEquals(new DateTime('2025-04-17T09:00:00+00:00'), $messageSchedule->getEmbargo()); + } + + public function testThrowsExceptionOnInvalidDto(): void + { + $this->expectException(InvalidArgumentException::class); + + $invalidDto = new \stdClass(); + $this->builder->buildFromDto($invalidDto); + } +} diff --git a/tests/Unit/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Service/Manager/TemplateImageManagerTest.php new file mode 100644 index 0000000..32d26af --- /dev/null +++ b/tests/Unit/Service/Manager/TemplateImageManagerTest.php @@ -0,0 +1,88 @@ +templateImageRepository = $this->createMock(TemplateImageRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new TemplateImageManager( + $this->templateImageRepository, + $this->entityManager + ); + } + + public function testCreateImagesFromImagePaths(): void + { + $template = $this->createMock(Template::class); + + $this->entityManager->expects($this->exactly(2)) + ->method('persist') + ->with($this->isInstanceOf(TemplateImage::class)); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $images = $this->manager->createImagesFromImagePaths(['image1.jpg', 'image2.png'], $template); + + $this->assertCount(2, $images); + foreach ($images as $image) { + $this->assertInstanceOf(TemplateImage::class, $image); + } + } + + public function testGuessMimeType(): void + { + $reflection = new \ReflectionClass($this->manager); + $method = $reflection->getMethod('guessMimeType'); + + $this->assertSame('image/jpeg', $method->invoke($this->manager, 'photo.jpg')); + $this->assertSame('image/png', $method->invoke($this->manager, 'picture.png')); + $this->assertSame('application/octet-stream', $method->invoke($this->manager, 'file.unknownext')); + } + + public function testExtractAllImages(): void + { + $html = '' . + '' . + '' . + '' . + 'Download' . + '' . + ''; + + $result = $this->manager->extractAllImages($html); + + $this->assertIsArray($result); + $this->assertContains('image1.jpg', $result); + $this->assertContains('https://example.com/image2.png', $result); + } + + public function testDeleteTemplateImage(): void + { + $templateImage = $this->createMock(TemplateImage::class); + + $this->templateImageRepository->expects($this->once()) + ->method('remove') + ->with($templateImage); + + $this->manager->delete($templateImage); + } +} diff --git a/tests/Unit/Validator/TemplateImageValidatorTest.php b/tests/Unit/Validator/TemplateImageValidatorTest.php new file mode 100644 index 0000000..35bac9d --- /dev/null +++ b/tests/Unit/Validator/TemplateImageValidatorTest.php @@ -0,0 +1,86 @@ +httpClient = $this->createMock(ClientInterface::class); + $this->validator = new TemplateImageValidator($this->httpClient); + } + + public function testThrowsExceptionIfValueIsNotArray(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->validator->validate('not-an-array'); + } + + public function testValidatesFullUrls(): void + { + $context = (new ValidationContext())->set('checkImages', true); + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/not-a-url/'); + + $this->validator->validate(['not-a-url', 'https://valid.url/image.jpg'], $context); + } + + public function testValidatesExistenceWithHttp200(): void + { + $context = (new ValidationContext())->set('checkExternalImages', true); + + $this->httpClient->expects($this->once()) + ->method('request') + ->with('HEAD', 'https://example.com/image.jpg') + ->willReturn(new Response(200)); + + $this->validator->validate(['https://example.com/image.jpg'], $context); + + $this->assertTrue(true); + } + + public function testValidatesExistenceWithHttp404(): void + { + $context = (new ValidationContext())->set('checkExternalImages', true); + + $this->httpClient->expects($this->once()) + ->method('request') + ->with('HEAD', 'https://example.com/missing.jpg') + ->willReturn(new Response(404)); + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/does not exist/'); + + $this->validator->validate(['https://example.com/missing.jpg'], $context); + } + + public function testValidatesExistenceThrowsHttpException(): void + { + $context = (new ValidationContext())->set('checkExternalImages', true); + + $this->httpClient->expects($this->once()) + ->method('request') + ->willThrowException(new \Exception('Connection failed')); + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/could not be validated/'); + + $this->validator->validate(['https://example.com/broken.jpg'], $context); + } +} diff --git a/tests/Unit/Validator/TemplateLinkValidatorTest.php b/tests/Unit/Validator/TemplateLinkValidatorTest.php new file mode 100644 index 0000000..78f8bd4 --- /dev/null +++ b/tests/Unit/Validator/TemplateLinkValidatorTest.php @@ -0,0 +1,66 @@ +validator = new TemplateLinkValidator(); + } + + public function testSkipsValidationIfNotString(): void + { + $context = (new ValidationContext())->set('checkLinks', true); + + $this->validator->validate(['not', 'a', 'string'], $context); + + $this->assertTrue(true); + } + + public function testSkipsValidationIfCheckLinksIsFalse(): void + { + $context = (new ValidationContext())->set('checkLinks', false); + + $this->validator->validate('Broken link', $context); + + $this->assertTrue(true); + } + + public function testValidatesInvalidLinks(): void + { + $context = (new ValidationContext())->set('checkLinks', true); + + $html = 'Broken'; + + $this->expectException(ValidatorException::class); + $this->expectExceptionMessageMatches('/invalid-link/'); + + $this->validator->validate($html, $context); + } + + public function testAllowsValidLinksAndPlaceholders(): void + { + $context = (new ValidationContext())->set('checkLinks', true); + + $html = '' . + 'Valid Link' . + 'Valid Link' . + 'Email Link' . + 'Placeholder' . + ''; + + $this->validator->validate($html, $context); + + $this->assertTrue(true); + } +} From ef8645275f95fb92536286e28e9d13b4614dc499 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 26 Apr 2025 15:50:32 +0400 Subject: [PATCH 51/62] ISSUE-345: delete template endpoint --- src/Controller/TemplateController.php | 50 +++++++++++ .../Service/Manager/TemplateManagerTest.php | 90 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/Unit/Service/Manager/TemplateManagerTest.php diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index c98e814..369271a 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -242,4 +242,54 @@ public function createTemplates(Request $request): JsonResponse Response::HTTP_CREATED ); } + + #[Route('/{templateId}', name: 'delete_template', methods: ['DELETE'])] + #[OA\Delete( + path: 'templates/{templateId}', + description: 'Deletes template by id.', + summary: 'Deletes a template.', + tags: ['templates'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'templateId', + description: 'Template ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: Response::HTTP_NO_CONTENT, + description: 'Success' + ), + 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 delete( + Request $request, + #[MapEntity(mapping: ['templateId' => 'id'])] Template $template + ): JsonResponse { + $this->requireAuthentication($request); + + $this->templateManager->delete($template); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } } diff --git a/tests/Unit/Service/Manager/TemplateManagerTest.php b/tests/Unit/Service/Manager/TemplateManagerTest.php new file mode 100644 index 0000000..d021218 --- /dev/null +++ b/tests/Unit/Service/Manager/TemplateManagerTest.php @@ -0,0 +1,90 @@ +templateRepository = $this->createMock(TemplateRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $this->templateImageManager = $this->createMock(TemplateImageManager::class); + $this->templateLinkValidator = $this->createMock(TemplateLinkValidator::class); + $this->templateImageValidator = $this->createMock(TemplateImageValidator::class); + + $this->manager = new TemplateManager( + $this->templateRepository, + $entityManager, + $this->templateImageManager, + $this->templateLinkValidator, + $this->templateImageValidator + ); + } + + public function testCreateTemplateSuccessfully(): void + { + $request = new CreateTemplateRequest(); + $request->title = 'Test Template'; + $request->content = 'Content'; + $request->text = 'Plain text'; + $request->checkLinks = true; + $request->checkImages = false; + $request->checkExternalImages = false; + $request->file = null; + + $this->templateLinkValidator->expects($this->once()) + ->method('validate') + ->with($request->content, $this->anything()); + + $this->templateImageManager->expects($this->once()) + ->method('extractAllImages') + ->with($request->content) + ->willReturn([]); + + $this->templateImageValidator->expects($this->once()) + ->method('validate') + ->with([], $this->anything()); + + $this->templateRepository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Template::class)); + + $this->templateImageManager->expects($this->once()) + ->method('createImagesFromImagePaths') + ->with([], $this->isInstanceOf(Template::class)); + + $template = $this->manager->create($request); + + $this->assertSame('Test Template', $template->getTitle()); + } + + public function testDeleteTemplate(): void + { + $template = $this->createMock(Template::class); + + $this->templateRepository->expects($this->once()) + ->method('remove') + ->with($template); + + $this->manager->delete($template); + } +} From 9601c116144f0fca51c9bfa45decd4130a778944 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 26 Apr 2025 16:08:18 +0400 Subject: [PATCH 52/62] ISSUE-345: fix 404 + template controller test --- config/services/providers.yml | 9 -- src/Controller/CampaignController.php | 24 +++- src/Controller/ListController.php | 13 +- src/Controller/SessionController.php | 18 ++- src/Controller/SubscriberController.php | 21 ++- src/Controller/SubscriptionController.php | 45 ++++-- src/Controller/TemplateController.php | 20 ++- src/Service/Manager/MessageManager.php | 6 + src/Service/Provider/MessageProvider.php | 26 ---- .../Controller/CampaignControllerTest.php | 8 ++ .../Fixtures/Messaging/Template.csv | 2 + .../Fixtures/Messaging/TemplateFixture.php | 48 +++++++ .../Controller/TemplateControllerTest.php | 133 ++++++++++++++++++ .../Service/Provider/MessageProviderTest.php | 39 ----- 14 files changed, 302 insertions(+), 110 deletions(-) delete mode 100644 config/services/providers.yml delete mode 100644 src/Service/Provider/MessageProvider.php create mode 100644 tests/Integration/Controller/Fixtures/Messaging/Template.csv create mode 100644 tests/Integration/Controller/Fixtures/Messaging/TemplateFixture.php create mode 100644 tests/Integration/Controller/TemplateControllerTest.php delete mode 100644 tests/Unit/Service/Provider/MessageProviderTest.php diff --git a/config/services/providers.yml b/config/services/providers.yml deleted file mode 100644 index 240bc00..0000000 --- a/config/services/providers.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - _defaults: - autowire: true - autoconfigure: true - public: false - - PhpList\RestBundle\Service\Provider\MessageProvider: - autowire: true - autoconfigure: true diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index f98573f..2db931c 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\SerializerInterface; @@ -32,20 +33,17 @@ class CampaignController extends AbstractController { use AuthenticationTrait; - private MessageProvider $messageProvider; private RequestValidator $validator; private MessageNormalizer $normalizer; private MessageManager $messageManager; public function __construct( Authentication $authentication, - MessageProvider $messageProvider, RequestValidator $validator, MessageNormalizer $normalizer, MessageManager $messageManager ) { $this->authentication = $authentication; - $this->messageProvider = $messageProvider; $this->validator = $validator; $this->normalizer = $normalizer; $this->messageManager = $messageManager; @@ -87,7 +85,7 @@ public function __construct( public function getMessages(Request $request): JsonResponse { $authUer = $this->requireAuthentication($request); - $data = $this->messageProvider->getMessagesByOwner($authUer); + $data = $this->messageManager->getMessagesByOwner($authUer); $normalized = array_map(function ($item) { return $this->normalizer->normalize($item); @@ -135,10 +133,14 @@ public function getMessages(Request $request): JsonResponse )] public function getMessage( Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] Message $message + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null ): JsonResponse { $this->requireAuthentication($request); + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + return new JsonResponse($this->normalizer->normalize($message), Response::HTTP_OK); } @@ -263,11 +265,15 @@ public function createMessage(Request $request, MessageNormalizer $normalizer): )] public function updateMessage( Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] Message $message, SerializerInterface $serializer, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, ): JsonResponse { $authUser = $this->requireAuthentication($request); + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + /** @return UpdateMessageRequest $updateMessageRequest */ $updateMessageRequest = $serializer->deserialize($request->getContent(), UpdateMessageRequest::class, 'json'); $updateMessageRequest->messageId = $message->getId(); @@ -325,10 +331,14 @@ public function updateMessage( )] public function deleteMessage( Request $request, - #[MapEntity(mapping: ['messageId' => 'id'])] Message $message + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null ): JsonResponse { $this->requireAuthentication($request); + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + $this->messageManager->delete($message); return new JsonResponse(null, Response::HTTP_NO_CONTENT); diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 3a4977c..e971388 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -176,10 +177,14 @@ public function getLists(Request $request): JsonResponse )] public function getList( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null ): JsonResponse { $this->requireAuthentication($request); + if (!$list) { + throw new NotFoundHttpException('Subscriber list not found.'); + } + return new JsonResponse($this->normalizer->normalize($list), Response::HTTP_OK); } @@ -224,10 +229,14 @@ public function getList( )] public function deleteList( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null ): JsonResponse { $this->requireAuthentication($request); + if (!$list) { + throw new NotFoundHttpException('Subscriber list not found.'); + } + $this->subscriberListManager->delete($list); return new JsonResponse(null, Response::HTTP_NO_CONTENT); diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index 24f3a1c..9e360d2 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -104,6 +105,7 @@ public function createSession( * This action may only be called for sessions that are owned by the authenticated administrator. * * @throws AccessDeniedHttpException + * @throws NotFoundHttpException */ #[Route('/{sessionId}', name: 'delete_session', methods: ['DELETE'])] #[OA\Delete( @@ -133,23 +135,19 @@ public function createSession( new OA\Response( response: 404, description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'There is no session with that ID.' - ) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ) ] )] public function deleteSession( Request $request, - #[MapEntity(mapping: ['sessionId' => 'id'])] AdministratorToken $token + #[MapEntity(mapping: ['sessionId' => 'id'])] ?AdministratorToken $token = null ): JsonResponse { $administrator = $this->requireAuthentication($request); + + if (!$token) { + throw new NotFoundHttpException('Token not found.'); + } if ($token->getAdministrator() !== $administrator) { throw new AccessDeniedHttpException('You do not have access to this session.', null, 1519831644); } diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 2e3773d..af46195 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\SerializerInterface; @@ -164,19 +165,24 @@ public function createSubscriber( ), new OA\Response( response: 404, - description: 'Not Found', + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ) ] )] public function updateSubscriber( Request $request, - #[MapEntity(mapping: ['subscriberId' => 'id'])] Subscriber $subscriber, SerializerInterface $serializer, RequestValidator $validator, SubscriberNormalizer $subscriberNormalizer, + #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$subscriber) { + throw new NotFoundHttpException('Subscriber not found.'); + } + /** @var UpdateSubscriberRequest $dto */ $dto = $serializer->deserialize($request->getContent(), UpdateSubscriberRequest::class, 'json'); $dto->subscriberId = $subscriber->getId(); @@ -221,7 +227,8 @@ public function updateSubscriber( ), new OA\Response( response: 404, - description: 'Not Found', + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ) ] )] @@ -268,16 +275,20 @@ public function getSubscriber(Request $request, int $subscriberId, SubscriberNor ), new OA\Response( response: 404, - description: 'Not Found', + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ) ] )] public function deleteSubscriber( Request $request, - #[MapEntity(mapping: ['subscriberId' => 'id'])] Subscriber $subscriber, + #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$subscriber) { + throw new NotFoundHttpException('Subscriber not found.'); + } $this->subscriberManager->deleteSubscriber($subscriber); return new JsonResponse(null, Response::HTTP_NO_CONTENT); diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index 3e1ef36..fdf56b2 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -78,16 +79,25 @@ public function __construct( 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 getListMembers( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, - SubscriberNormalizer $normalizer + SubscriberNormalizer $normalizer, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$list) { + throw new NotFoundHttpException('Subscriber list not found.'); + } + $subscribers = $this->subscriptionManager->getSubscriberListMembers($list); $normalized = array_map(function ($item) use ($normalizer) { return $normalizer->normalize($item); @@ -142,10 +152,14 @@ public function getListMembers( )] public function getSubscribersCount( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$list) { + throw new NotFoundHttpException('Subscriber list not found.'); + } + return new JsonResponse(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); } @@ -195,15 +209,20 @@ public function getSubscribersCount( items: new OA\Items(ref: '#/components/schemas/Subscription') ) ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), new OA\Response( response: 403, description: 'Failure', content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( - response: 400, + response: 404, description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ), new OA\Response( response: 409, @@ -219,11 +238,15 @@ public function getSubscribersCount( )] public function createSubscription( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, - SubscriptionNormalizer $serializer + SubscriptionNormalizer $serializer, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$list) { + throw new NotFoundHttpException('Subscriber list not found.'); + } + /** @var SubscriptionRequest $subscriptionRequest */ $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); @@ -276,15 +299,19 @@ public function createSubscription( ), new OA\Response( response: 404, - description: 'Subscriber or subscription not found.' + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ) ] )] public function deleteSubscriptions( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$list) { + throw new NotFoundHttpException('Subscriber list not found.'); + } $subscriptionRequest = new SubscriptionRequest(); $subscriptionRequest->emails = $request->query->all('emails'); diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index 369271a..f731788 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -128,15 +129,24 @@ public function getTemplates(Request $request): JsonResponse 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 getTemplate( Request $request, - #[MapEntity(mapping: ['templateId' => 'id'])] Template $template + #[MapEntity(mapping: ['templateId' => 'id'])] ?Template $template = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$template) { + throw $this->createNotFoundException('Template not found.'); + } + return new JsonResponse($this->normalizer->normalize($template), Response::HTTP_OK); } @@ -245,7 +255,7 @@ public function createTemplates(Request $request): JsonResponse #[Route('/{templateId}', name: 'delete_template', methods: ['DELETE'])] #[OA\Delete( - path: 'templates/{templateId}', + path: '/templates/{templateId}', description: 'Deletes template by id.', summary: 'Deletes a template.', tags: ['templates'], @@ -284,10 +294,14 @@ public function createTemplates(Request $request): JsonResponse )] public function delete( Request $request, - #[MapEntity(mapping: ['templateId' => 'id'])] Template $template + #[MapEntity(mapping: ['templateId' => 'id'])] ?Template $template = null, ): JsonResponse { $this->requireAuthentication($request); + if (!$template) { + throw new NotFoundHttpException('Template not found.'); + } + $this->templateManager->delete($template); return new JsonResponse(null, Response::HTTP_NO_CONTENT); diff --git a/src/Service/Manager/MessageManager.php b/src/Service/Manager/MessageManager.php index fa083c8..96dc11d 100644 --- a/src/Service/Manager/MessageManager.php +++ b/src/Service/Manager/MessageManager.php @@ -48,4 +48,10 @@ public function delete(Message $message): void { $this->messageRepository->remove($message); } + + /** @return Message[] */ + public function getMessagesByOwner(Administrator $owner): array + { + return $this->messageRepository->getByOwnerId($owner->getId()); + } } diff --git a/src/Service/Provider/MessageProvider.php b/src/Service/Provider/MessageProvider.php deleted file mode 100644 index dce8341..0000000 --- a/src/Service/Provider/MessageProvider.php +++ /dev/null @@ -1,26 +0,0 @@ -messageRepository = $messageRepository; - } - - /** @return Message[] */ - public function getMessagesByOwner(Administrator $owner): array - { - return $this->messageRepository->getByOwnerId($owner->getId()); - } -} diff --git a/tests/Integration/Controller/CampaignControllerTest.php b/tests/Integration/Controller/CampaignControllerTest.php index 605f97c..6d56dc3 100644 --- a/tests/Integration/Controller/CampaignControllerTest.php +++ b/tests/Integration/Controller/CampaignControllerTest.php @@ -78,4 +78,12 @@ public function testGetCampaignWithInvalidIdReturnsNotFound(): void $this->authenticatedJsonRequest('GET', '/api/v2/campaigns/999'); $this->assertHttpNotFound(); } + + public function testDeleteCampaignReturnsNoContent(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/campaigns/1'); + $this->assertHttpNoContent(); + } } diff --git a/tests/Integration/Controller/Fixtures/Messaging/Template.csv b/tests/Integration/Controller/Fixtures/Messaging/Template.csv new file mode 100644 index 0000000..65872d6 --- /dev/null +++ b/tests/Integration/Controller/Fixtures/Messaging/Template.csv @@ -0,0 +1,2 @@ +id,title,template,template_text,listorder +1,Newsletter Template,

Welcome

,,1 diff --git a/tests/Integration/Controller/Fixtures/Messaging/TemplateFixture.php b/tests/Integration/Controller/Fixtures/Messaging/TemplateFixture.php new file mode 100644 index 0000000..8daf6e5 --- /dev/null +++ b/tests/Integration/Controller/Fixtures/Messaging/TemplateFixture.php @@ -0,0 +1,48 @@ +setContent($row['template']); + $template->setText($row['template_text']); + $template->setListOrder((int)$row['listorder']); + + $this->setSubjectId($template, (int)$row['id']); + $manager->persist($template); + } while (true); + + fclose($handle); + } +} diff --git a/tests/Integration/Controller/TemplateControllerTest.php b/tests/Integration/Controller/TemplateControllerTest.php new file mode 100644 index 0000000..793b9c3 --- /dev/null +++ b/tests/Integration/Controller/TemplateControllerTest.php @@ -0,0 +1,133 @@ +get(TemplateController::class)); + } + + public function testGetTemplatesWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/templates'); + $this->assertHttpForbidden(); + } + + public function testGetTemplatesWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/templates', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testGetTemplatesWithValidSessionKeyReturnsOkay(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/templates'); + $this->assertHttpOkay(); + } + + public function testGetTemplatesReturnsTemplateData(): void + { + $this->loadFixtures([TemplateFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/templates'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('id', $response[0]); + self::assertArrayHasKey('title', $response[0]); + } + + public function testGetTemplateWithoutSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([TemplateFixture::class]); + + self::getClient()->request('GET', '/api/v2/templates/1'); + $this->assertHttpForbidden(); + } + + public function testGetTemplateWithValidSessionKeyReturnsOkay(): void + { + $this->loadFixtures([TemplateFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/templates/1'); + $this->assertHttpOkay(); + } + + public function testGetTemplateWithInvalidIdReturnsNotFound(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/templates/999'); + $this->assertHttpNotFound(); + } + + public function testCreateTemplateWithValidDataReturnsCreated(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + $payload = json_encode([ + 'title' => 'New Template', + 'content' => '[CONTENT]', + 'text' => '[CONTENT]', + 'check_links' => true, + 'check_images' => false, + 'check_external_images' => false, + ]); + + $this->authenticatedJsonRequest('POST', '/api/v2/templates', [], [], [], $payload); + $this->assertHttpCreated(); + } + + public function testCreateTemplateMissingTitleReturnsValidationError(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + $payload = json_encode([ + 'content' => '[CONTENT]', + ]); + + $this->authenticatedJsonRequest('POST', '/api/v2/templates', [], [], [], $payload); + $this->assertHttpUnprocessableEntity(); + } + + public function testDeleteTemplateWithValidSessionKeyReturnsNoContent(): void + { + $this->loadFixtures([TemplateFixture::class]); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/templates/1'); + $this->assertHttpNoContent(); + } + + public function testDeleteTemplateWithInvalidIdReturnsNotFound(): void + { + $this->authenticatedJsonRequest('DELETE', '/api/v2/templates/999'); + $this->assertHttpNotFound(); + } + + public function testDeleteTemplateActuallyDeletes(): void + { + $this->loadFixtures([TemplateFixture::class]); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/templates/1'); + + $templateRepository = self::getContainer()->get(TemplateRepository::class); + self::assertNull($templateRepository->find(1)); + } +} diff --git a/tests/Unit/Service/Provider/MessageProviderTest.php b/tests/Unit/Service/Provider/MessageProviderTest.php deleted file mode 100644 index 68127b5..0000000 --- a/tests/Unit/Service/Provider/MessageProviderTest.php +++ /dev/null @@ -1,39 +0,0 @@ -createMock(Message::class); - $message2 = $this->createMock(Message::class); - $expectedMessages = [$message1, $message2]; - - $repository = $this->createMock(MessageRepository::class); - $repository->expects($this->once()) - ->method('getByOwnerId') - ->with($ownerId) - ->willReturn($expectedMessages); - - $owner = $this->createMock(Administrator::class); - $owner->expects($this->once()) - ->method('getId') - ->willReturn($ownerId); - - $provider = new MessageProvider($repository); - - $result = $provider->getMessagesByOwner($owner); - - $this->assertSame($expectedMessages, $result); - } -} From 0a657160fa1bffe34136c07d6103c8b112757783 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 29 Apr 2025 20:38:41 +0400 Subject: [PATCH 53/62] ISSUE-345: admin controller --- src/Controller/AdministratorController.php | 200 ++++++++++++++++++ .../Request/CreateAdministratorRequest.php | 26 +++ .../Request/UpdateAdministratorRequest.php | 22 ++ src/OpenApi/SwaggerSchemasRequestDto.php | 65 ++++++ src/Serializer/AdministratorNormalizer.php | 32 +++ src/Service/Manager/AdministratorManager.php | 63 ++++++ 6 files changed, 408 insertions(+) create mode 100644 src/Controller/AdministratorController.php create mode 100644 src/Entity/Request/CreateAdministratorRequest.php create mode 100644 src/Entity/Request/UpdateAdministratorRequest.php create mode 100644 src/Serializer/AdministratorNormalizer.php create mode 100644 src/Service/Manager/AdministratorManager.php diff --git a/src/Controller/AdministratorController.php b/src/Controller/AdministratorController.php new file mode 100644 index 0000000..277b2cf --- /dev/null +++ b/src/Controller/AdministratorController.php @@ -0,0 +1,200 @@ +administratorManager = $administratorManager; + } + + #[Route('', name: 'create_administrator', methods: ['POST'])] + #[OA\Post( + path: '/administrators', + description: 'Create a new administrator.', + summary: 'Create Administrator', + requestBody: new OA\RequestBody( + description: 'Administrator data', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/CreateAdministratorRequest') + ), + tags: ['administrators'], + responses: [ + new OA\Response( + response: 201, + description: 'Administrator created successfully', + content: new OA\JsonContent(ref: '#/components/schemas/CreateAdministratorRequest') + ), + new OA\Response( + response: 400, + description: 'Invalid input' + ) + ] + )] + public function createAdministrator( + Request $request, + RequestValidator $validator, + AdministratorNormalizer $normalizer + ): JsonResponse { + /** @var CreateAdministratorRequest $dto */ + $dto = $validator->validate($request, CreateAdministratorRequest::class); + + $administrator = $this->administratorManager->createAdministrator($dto); + + $json = $normalizer->normalize($administrator, 'json'); + + return new JsonResponse($json, Response::HTTP_CREATED); + } + + #[Route('/{administratorId}', name: 'get_administrator', methods: ['GET'])] + #[OA\Get( + path: '/administrators/{administratorId}', + description: 'Get administrator by ID.', + summary: 'Get Administrator', + tags: ['administrators'], + parameters: [ + new OA\Parameter( + name: 'administratorId', + description: 'Administrator ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Administrator found', + content: new OA\JsonContent(ref: '#/components/schemas/Administrator') + ), + new OA\Response( + response: 404, + description: 'Administrator not found' + ) + ] + )] + public function getAdministrator( + #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator, + AdministratorNormalizer $normalizer + ): JsonResponse { + if (!$administrator) { + return new JsonResponse(['message' => 'Administrator not found.'], Response::HTTP_NOT_FOUND); + } + + $json = $normalizer->normalize($administrator, 'json'); + + return new JsonResponse($json); + } + + #[Route('/{administratorId}', name: 'update_administrator', methods: ['PUT'])] + #[OA\Put( + path: '/administrators/{administratorId}', + description: 'Update an administrator.', + summary: 'Update Administrator', + requestBody: new OA\RequestBody( + description: 'Administrator update data', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/UpdateAdministratorRequest') + ), + tags: ['administrators'], + parameters: [ + new OA\Parameter( + name: 'administratorId', + description: 'Administrator ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Administrator updated successfully' + ), + new OA\Response( + response: 404, + description: 'Administrator not found' + ) + ] + )] + public function updateAdministrator( + Request $request, + #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator, + RequestValidator $validator + ): JsonResponse { + if (!$administrator) { + return new JsonResponse(['message' => 'Administrator not found.'], Response::HTTP_NOT_FOUND); + } + + /** @var UpdateAdministratorRequest $dto */ + $dto = $validator->validate($request, UpdateAdministratorRequest::class); + + $this->administratorManager->updateAdministrator($administrator, $dto); + + return new JsonResponse(null, Response::HTTP_OK); + } + + #[Route('/{administratorId}', name: 'delete_administrator', methods: ['DELETE'])] + #[OA\Delete( + path: '/administrators/{administratorId}', + description: 'Delete an administrator.', + summary: 'Delete Administrator', + tags: ['administrators'], + parameters: [ + new OA\Parameter( + name: 'administratorId', + description: 'Administrator ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 204, + description: 'Administrator deleted successfully' + ), + new OA\Response( + response: 404, + description: 'Administrator not found' + ) + ] + )] + public function deleteAdministrator( + #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator + ): JsonResponse { + if (!$administrator) { + return new JsonResponse(['message' => 'Administrator not found.'], Response::HTTP_NOT_FOUND); + } + + $this->administratorManager->deleteAdministrator($administrator); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Entity/Request/CreateAdministratorRequest.php b/src/Entity/Request/CreateAdministratorRequest.php new file mode 100644 index 0000000..03ef371 --- /dev/null +++ b/src/Entity/Request/CreateAdministratorRequest.php @@ -0,0 +1,26 @@ + $object->getId(), + 'login_name' => $object->getLoginName(), + 'email' => $object->getEmail(), + 'super_admin' => $object->isSuperAdmin(), + 'created_at' => $object->getCreatedAt()?->format(\DateTimeInterface::ATOM), + ]; + } + + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Administrator; + } +} diff --git a/src/Service/Manager/AdministratorManager.php b/src/Service/Manager/AdministratorManager.php new file mode 100644 index 0000000..11d4d8d --- /dev/null +++ b/src/Service/Manager/AdministratorManager.php @@ -0,0 +1,63 @@ +entityManager = $entityManager; + $this->passwordHasher = $passwordHasher; + } + + public function createAdministrator(CreateAdministratorRequest $dto): Administrator + { + $administrator = new Administrator(); + $administrator->setLoginName($dto->loginName); + $administrator->setEmail($dto->email); + $administrator->setSuperUser($dto->superUser); + $hashedPassword = $this->passwordHasher->hashPassword($administrator, $dto->password); + $administrator->setPasswordHash($hashedPassword); + + $this->entityManager->persist($administrator); + $this->entityManager->flush(); + + return $administrator; + } + + public function updateAdministrator(Administrator $administrator, UpdateAdministratorRequest $dto): void + { + if ($dto->loginName !== null) { + $administrator->setLoginName($dto->loginName); + } + if ($dto->email !== null) { + $administrator->setEmail($dto->email); + } + if ($dto->superAdmin !== null) { + $administrator->setSuperUser($dto->superAdmin); + } + if ($dto->password !== null) { + $hashedPassword = $this->passwordHasher->hashPassword($administrator, $dto->password); + $administrator->setPasswordHash($hashedPassword); + } + + $this->entityManager->flush(); + } + + public function deleteAdministrator(Administrator $administrator): void + { + $this->entityManager->remove($administrator); + $this->entityManager->flush(); + } +} From bf0219ca8462b07fad247208370be31a34c746a1 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 29 Apr 2025 23:00:49 +0400 Subject: [PATCH 54/62] ISSUE-345: fix createdAt/updatedAt --- config/services/managers.yml | 4 ++++ config/services/normalizers.yml | 4 ++++ src/Serializer/AdministratorNormalizer.php | 12 ++++++++++-- src/Serializer/SubscriberListNormalizer.php | 2 +- src/Serializer/SubscriberNormalizer.php | 6 +++--- src/Serializer/SubscriptionNormalizer.php | 2 +- src/Service/Manager/AdministratorManager.php | 12 ++++++------ .../Fixtures/Identity/AdministratorFixture.php | 4 ++-- .../Fixtures/Identity/AdministratorTokenFixture.php | 2 +- .../Controller/Fixtures/Messaging/MessageFixture.php | 2 +- .../Fixtures/Messaging/SubscriberListFixture.php | 4 ++-- .../Fixtures/Subscription/SubscriberFixture.php | 4 ++-- .../Fixtures/Subscription/SubscriptionFixture.php | 4 ++-- tests/Integration/Controller/ListControllerTest.php | 10 +++++----- .../Unit/Serializer/SubscriberListNormalizerTest.php | 2 +- tests/Unit/Serializer/SubscriberNormalizerTest.php | 10 +++++----- tests/Unit/Serializer/SubscriptionNormalizerTest.php | 2 +- 17 files changed, 51 insertions(+), 35 deletions(-) diff --git a/config/services/managers.yml b/config/services/managers.yml index 3024af2..7f42416 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -31,3 +31,7 @@ services: PhpList\RestBundle\Service\Manager\TemplateImageManager: autowire: true autoconfigure: true + + PhpList\RestBundle\Service\Manager\AdministratorManager: + autowire: true + autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index e809616..642da45 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -38,3 +38,7 @@ services: PhpList\RestBundle\Serializer\TemplateNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Serializer\AdministratorNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Serializer/AdministratorNormalizer.php b/src/Serializer/AdministratorNormalizer.php index 44e01d2..1eb5327 100644 --- a/src/Serializer/AdministratorNormalizer.php +++ b/src/Serializer/AdministratorNormalizer.php @@ -4,12 +4,17 @@ namespace PhpList\RestBundle\Serializer; +use DateTimeInterface; use InvalidArgumentException; use PhpList\Core\Domain\Model\Identity\Administrator; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; class AdministratorNormalizer implements NormalizerInterface { + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws InvalidArgumentException + */ public function normalize($object, string $format = null, array $context = []): array { if (!$object instanceof Administrator) { @@ -20,11 +25,14 @@ public function normalize($object, string $format = null, array $context = []): 'id' => $object->getId(), 'login_name' => $object->getLoginName(), 'email' => $object->getEmail(), - 'super_admin' => $object->isSuperAdmin(), - 'created_at' => $object->getCreatedAt()?->format(\DateTimeInterface::ATOM), + 'super_admin' => $object->isSuperUser(), + 'created_at' => $object->getCreatedAt()?->format(DateTimeInterface::ATOM), ]; } + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ public function supportsNormalization($data, string $format = null): bool { return $data instanceof Administrator; diff --git a/src/Serializer/SubscriberListNormalizer.php b/src/Serializer/SubscriberListNormalizer.php index c2bf87e..406ff62 100644 --- a/src/Serializer/SubscriberListNormalizer.php +++ b/src/Serializer/SubscriberListNormalizer.php @@ -21,7 +21,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'name' => $object->getName(), - 'creation_date' => $object->getCreationDate()->format('Y-m-d\TH:i:sP'), + 'creation_date' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 'description' => $object->getDescription(), 'list_position' => $object->getListPosition(), 'subject_prefix' => $object->getSubjectPrefix(), diff --git a/src/Serializer/SubscriberNormalizer.php b/src/Serializer/SubscriberNormalizer.php index fbde5ce..fb175b5 100644 --- a/src/Serializer/SubscriberNormalizer.php +++ b/src/Serializer/SubscriberNormalizer.php @@ -22,7 +22,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'email' => $object->getEmail(), - 'creation_date' => $object->getCreationDate()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 'confirmed' => $object->isConfirmed(), 'blacklisted' => $object->isBlacklisted(), 'bounce_count' => $object->getBounceCount(), @@ -34,9 +34,9 @@ public function normalize($object, string $format = null, array $context = []): 'id' => $subscription->getSubscriberList()->getId(), 'name' => $subscription->getSubscriberList()->getName(), 'description' => $subscription->getSubscriberList()->getDescription(), - 'creation_date' => $subscription->getSubscriberList()->getCreationDate()->format('Y-m-d\TH:i:sP'), + 'created_at' => $subscription->getSubscriberList()->getCreatedAt()->format('Y-m-d\TH:i:sP'), 'public' => $subscription->getSubscriberList()->isPublic(), - 'subscription_date' => $subscription->getCreationDate()->format('Y-m-d\TH:i:sP'), + 'subscription_date' => $subscription->getCreatedAt()->format('Y-m-d\TH:i:sP'), ]; }, $object->getSubscriptions()->toArray()), ]; diff --git a/src/Serializer/SubscriptionNormalizer.php b/src/Serializer/SubscriptionNormalizer.php index fbdca38..58df4fb 100644 --- a/src/Serializer/SubscriptionNormalizer.php +++ b/src/Serializer/SubscriptionNormalizer.php @@ -32,7 +32,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->getCreationDate()->format('Y-m-d\TH:i:sP'), + 'subscription_date' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), ]; } diff --git a/src/Service/Manager/AdministratorManager.php b/src/Service/Manager/AdministratorManager.php index 11d4d8d..a56a809 100644 --- a/src/Service/Manager/AdministratorManager.php +++ b/src/Service/Manager/AdministratorManager.php @@ -8,17 +8,17 @@ use PhpList\Core\Domain\Model\Identity\Administrator; use PhpList\RestBundle\Entity\Request\CreateAdministratorRequest; use PhpList\RestBundle\Entity\Request\UpdateAdministratorRequest; -use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use PhpList\Core\Security\HashGenerator; class AdministratorManager { private EntityManagerInterface $entityManager; - private UserPasswordHasherInterface $passwordHasher; + private HashGenerator $hashGenerator; - public function __construct(EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher) + public function __construct(EntityManagerInterface $entityManager, HashGenerator $hashGenerator) { $this->entityManager = $entityManager; - $this->passwordHasher = $passwordHasher; + $this->hashGenerator = $hashGenerator; } public function createAdministrator(CreateAdministratorRequest $dto): Administrator @@ -27,7 +27,7 @@ public function createAdministrator(CreateAdministratorRequest $dto): Administra $administrator->setLoginName($dto->loginName); $administrator->setEmail($dto->email); $administrator->setSuperUser($dto->superUser); - $hashedPassword = $this->passwordHasher->hashPassword($administrator, $dto->password); + $hashedPassword = $this->hashGenerator->createPasswordHash($dto->password); $administrator->setPasswordHash($hashedPassword); $this->entityManager->persist($administrator); @@ -48,7 +48,7 @@ public function updateAdministrator(Administrator $administrator, UpdateAdminist $administrator->setSuperUser($dto->superAdmin); } if ($dto->password !== null) { - $hashedPassword = $this->passwordHasher->hashPassword($administrator, $dto->password); + $hashedPassword = $this->hashGenerator->createPasswordHash($dto->password); $administrator->setPasswordHash($hashedPassword); } diff --git a/tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php b/tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php index ede810b..282c79c 100644 --- a/tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php +++ b/tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php @@ -39,14 +39,14 @@ public function load(ObjectManager $manager): void $admin = new Administrator(); $this->setSubjectId($admin, (int)$row['id']); $admin->setLoginName($row['loginname']); - $admin->setEmailAddress($row['email']); + $admin->setEmail($row['email']); $admin->setPasswordHash($row['password']); $admin->setDisabled((bool) $row['disabled']); $admin->setSuperUser((bool) $row['superuser']); $manager->persist($admin); - $this->setSubjectProperty($admin, 'creationDate', new DateTime($row['created'])); + $this->setSubjectProperty($admin, 'createdAt', new DateTime($row['created'])); $this->setSubjectProperty($admin, 'passwordChangeDate', new DateTime($row['passwordchanged'])); } while (true); diff --git a/tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php b/tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php index a4f4aec..b22998d 100644 --- a/tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php +++ b/tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php @@ -53,7 +53,7 @@ public function load(ObjectManager $manager): void $manager->persist($adminToken); $this->setSubjectProperty($adminToken, 'expiry', new DateTime($row['expires'])); - $this->setSubjectProperty($adminToken, 'creationDate', (bool) $row['entered']); + $this->setSubjectProperty($adminToken, 'createdAt', (bool) $row['entered']); } while (true); fclose($handle); diff --git a/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php b/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php index fbe6f83..0986b4f 100644 --- a/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php +++ b/tests/Integration/Controller/Fixtures/Messaging/MessageFixture.php @@ -101,7 +101,7 @@ public function load(ObjectManager $manager): void $this->setSubjectProperty($message, 'uuid', $row['uuid']); $manager->persist($message); - $this->setSubjectProperty($message, 'modificationDate', new DateTime($row['modified'])); + $this->setSubjectProperty($message, 'updatedAt', new DateTime($row['modified'])); } while (true); fclose($handle); diff --git a/tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php b/tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php index b64d6b9..4d743f1 100644 --- a/tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php +++ b/tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php @@ -60,8 +60,8 @@ public function load(ObjectManager $manager): void $manager->persist($subscriberList); - $this->setSubjectProperty($subscriberList, 'creationDate', new DateTime($row['entered'])); - $this->setSubjectProperty($subscriberList, 'modificationDate', new DateTime($row['modified'])); + $this->setSubjectProperty($subscriberList, 'createdAt', new DateTime($row['entered'])); + $this->setSubjectProperty($subscriberList, 'updatedAt', new DateTime($row['modified'])); } while (true); fclose($handle); diff --git a/tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php b/tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php index 3281cd1..7992ea8 100644 --- a/tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php +++ b/tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php @@ -50,8 +50,8 @@ public function load(ObjectManager $manager): void $manager->persist($subscriber); // avoid pre-persist $subscriber->setUniqueId($row['uniqid']); - $this->setSubjectProperty($subscriber, 'creationDate', new DateTime($row['entered'])); - $this->setSubjectProperty($subscriber, 'modificationDate', new DateTime($row['modified'])); + $this->setSubjectProperty($subscriber, 'createdAt', new DateTime($row['entered'])); + $this->setSubjectProperty($subscriber, 'updatedAt', new DateTime($row['modified'])); } while (true); fclose($handle); diff --git a/tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php b/tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php index f6eebf4..a87eed3 100644 --- a/tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php +++ b/tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php @@ -51,8 +51,8 @@ public function load(ObjectManager $manager): void $manager->persist($subscription); - $this->setSubjectProperty($subscription, 'creationDate', new DateTime($row['entered'])); - $this->setSubjectProperty($subscription, 'modificationDate', new DateTime($row['modified'])); + $this->setSubjectProperty($subscription, 'createdAt', new DateTime($row['entered'])); + $this->setSubjectProperty($subscription, 'updatedAt', new DateTime($row['modified'])); } while (true); fclose($handle); diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index 2a9df5f..0fb9e9a 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -240,7 +240,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr [ 'id' => 1, 'email' => 'oliver@example.com', - 'creation_date' => '2016-07-22T15:01:17+00:00', + 'created_at' => '2016-07-22T15:01:17+00:00', 'confirmed' => true, 'blacklisted' => true, 'bounce_count' => 17, @@ -252,7 +252,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'id' => 2, 'name' => 'More news', 'description' => '', - 'creation_date' => '2016-06-22T15:01:17+00:00', + 'created_at' => '2016-06-22T15:01:17+00:00', 'public' => true, 'subscription_date' => '2016-07-22T15:01:17+00:00', ], @@ -260,7 +260,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr ], [ 'id' => 2, 'email' => 'oliver1@example.com', - 'creation_date' => '2016-07-22T15:01:17+00:00', + 'created_at' => '2016-07-22T15:01:17+00:00', 'confirmed' => true, 'blacklisted' => true, 'bounce_count' => 17, @@ -272,7 +272,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'id' => 2, 'name' => 'More news', 'description' => '', - 'creation_date' => '2016-06-22T15:01:17+00:00', + 'created_at' => '2016-06-22T15:01:17+00:00', 'public' => true, 'subscription_date' => '2016-08-22T15:01:17+00:00', ], @@ -280,7 +280,7 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'id' => 1, 'name' => 'News', 'description' => 'News (and some fun stuff)', - 'creation_date' => '2016-06-22T15:01:17+00:00', + 'created_at' => '2016-06-22T15:01:17+00:00', 'public' => true, 'subscription_date' => '2016-09-22T15:01:17+00:00', ], diff --git a/tests/Unit/Serializer/SubscriberListNormalizerTest.php b/tests/Unit/Serializer/SubscriberListNormalizerTest.php index 0909833..a3efa12 100644 --- a/tests/Unit/Serializer/SubscriberListNormalizerTest.php +++ b/tests/Unit/Serializer/SubscriberListNormalizerTest.php @@ -25,7 +25,7 @@ public function testNormalize(): void $mock = $this->createMock(SubscriberList::class); $mock->method('getId')->willReturn(101); $mock->method('getName')->willReturn('Tech News'); - $mock->method('getCreationDate')->willReturn(new DateTime('2025-04-01T10:00:00+00:00')); + $mock->method('getCreatedAt')->willReturn(new DateTime('2025-04-01T10:00:00+00:00')); $mock->method('getDescription')->willReturn('All tech updates'); $mock->method('getListPosition')->willReturn(2); $mock->method('getSubjectPrefix')->willReturn('tech'); diff --git a/tests/Unit/Serializer/SubscriberNormalizerTest.php b/tests/Unit/Serializer/SubscriberNormalizerTest.php index 1d35196..b0d6168 100644 --- a/tests/Unit/Serializer/SubscriberNormalizerTest.php +++ b/tests/Unit/Serializer/SubscriberNormalizerTest.php @@ -30,17 +30,17 @@ public function testNormalize(): void $subscriberList->method('getId')->willReturn(1); $subscriberList->method('getName')->willReturn('News'); $subscriberList->method('getDescription')->willReturn('Latest news'); - $subscriberList->method('getCreationDate')->willReturn(new DateTime('2025-01-01T00:00:00+00:00')); + $subscriberList->method('getCreatedAt')->willReturn(new DateTime('2025-01-01T00:00:00+00:00')); $subscriberList->method('isPublic')->willReturn(true); $subscription = $this->createMock(Subscription::class); $subscription->method('getSubscriberList')->willReturn($subscriberList); - $subscription->method('getCreationDate')->willReturn(new DateTime('2025-01-10T00:00:00+00:00')); + $subscription->method('getCreatedAt')->willReturn(new DateTime('2025-01-10T00:00:00+00:00')); $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getId')->willReturn(101); $subscriber->method('getEmail')->willReturn('test@example.com'); - $subscriber->method('getCreationDate')->willReturn(new DateTime('2024-12-31T12:00:00+00:00')); + $subscriber->method('getCreatedAt')->willReturn(new DateTime('2024-12-31T12:00:00+00:00')); $subscriber->method('isConfirmed')->willReturn(true); $subscriber->method('isBlacklisted')->willReturn(false); $subscriber->method('getBounceCount')->willReturn(0); @@ -54,7 +54,7 @@ public function testNormalize(): void $expected = [ 'id' => 101, 'email' => 'test@example.com', - 'creation_date' => '2024-12-31T12:00:00+00:00', + 'created_at' => '2024-12-31T12:00:00+00:00', 'confirmed' => true, 'blacklisted' => false, 'bounce_count' => 0, @@ -66,7 +66,7 @@ public function testNormalize(): void 'id' => 1, 'name' => 'News', 'description' => 'Latest news', - 'creation_date' => '2025-01-01T00:00:00+00:00', + 'created_at' => '2025-01-01T00:00:00+00:00', 'public' => true, 'subscription_date' => '2025-01-10T00:00:00+00:00' ] diff --git a/tests/Unit/Serializer/SubscriptionNormalizerTest.php b/tests/Unit/Serializer/SubscriptionNormalizerTest.php index 4a2e446..b7de91f 100644 --- a/tests/Unit/Serializer/SubscriptionNormalizerTest.php +++ b/tests/Unit/Serializer/SubscriptionNormalizerTest.php @@ -36,7 +36,7 @@ public function testNormalize(): void $subscription = $this->createMock(Subscription::class); $subscription->method('getSubscriber')->willReturn($subscriber); $subscription->method('getSubscriberList')->willReturn($subscriberList); - $subscription->method('getCreationDate')->willReturn($subscriptionDate); + $subscription->method('getCreatedAt')->willReturn($subscriptionDate); $subscriberNormalizer = $this->createMock(SubscriberNormalizer::class); $subscriberListNormalizer = $this->createMock(SubscriberListNormalizer::class); From 33d3eee8611f77835b1199590c06eeedd3d85c08 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 29 Apr 2025 23:06:53 +0400 Subject: [PATCH 55/62] ISSUE-345: fix swagger --- src/OpenApi/SwaggerSchemasResponseEntity.php | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/OpenApi/SwaggerSchemasResponseEntity.php b/src/OpenApi/SwaggerSchemasResponseEntity.php index 7afe4c2..920260d 100644 --- a/src/OpenApi/SwaggerSchemasResponseEntity.php +++ b/src/OpenApi/SwaggerSchemasResponseEntity.php @@ -174,6 +174,40 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'Administrator', + properties: [ + new OA\Property( + property: 'id', + type: 'integer', + example: 1 + ), + new OA\Property( + property: 'login_name', + type: 'string', + example: 'admin' + ), + new OA\Property( + property: 'email', + type: 'string', + format: 'email', + example: 'admin@example.com' + ), + new OA\Property( + property: 'super_user', + type: 'boolean', + example: true + ), + new OA\Property( + property: 'created_at', + type: 'string', + format: 'date-time', + example: '2025-04-29T12:34:56+00:00' + ), + ], + type: 'object' +)] + class SwaggerSchemasResponseEntity { } From 3d12debd5580c305df0ac34bfb5198b17abf42d8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 3 May 2025 15:55:26 +0400 Subject: [PATCH 56/62] ISSUE-345: list cursor pagination --- config/services/factories.yml | 9 ++ config/services/normalizers.yml | 3 + config/services/providers.yml | 9 ++ src/Controller/ListController.php | 95 ++++++++----------- src/Entity/Dto/CursorPaginationResult.php | 15 +++ .../Request/PaginationCursorRequest.php | 27 ++++++ src/OpenApi/SwaggerSchemasResponse.php | 11 +++ src/OpenApi/SwaggerSchemasResponseEntity.php | 4 +- src/Serializer/CursorPaginationNormalizer.php | 41 ++++++++ src/Serializer/SubscriberListNormalizer.php | 2 +- .../PaginationCursorRequestFactory.php | 19 ++++ src/Service/Manager/SubscriberListManager.php | 14 ++- .../Provider/SubscriberListProvider.php | 33 +++++++ .../Controller/ListControllerTest.php | 22 +++-- .../SubscriberListNormalizerTest.php | 2 +- .../Manager/SubscriberListManagerTest.php | 7 +- 16 files changed, 239 insertions(+), 74 deletions(-) create mode 100644 config/services/factories.yml create mode 100644 config/services/providers.yml create mode 100644 src/Entity/Dto/CursorPaginationResult.php create mode 100644 src/Entity/Request/PaginationCursorRequest.php create mode 100644 src/Serializer/CursorPaginationNormalizer.php create mode 100644 src/Service/Factory/PaginationCursorRequestFactory.php create mode 100644 src/Service/Provider/SubscriberListProvider.php diff --git a/config/services/factories.yml b/config/services/factories.yml new file mode 100644 index 0000000..3a8734d --- /dev/null +++ b/config/services/factories.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Service\Factory\PaginationCursorRequestFactory: + autowire: true + autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 642da45..ab3d3d7 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -42,3 +42,6 @@ services: PhpList\RestBundle\Serializer\AdministratorNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Serializer\CursorPaginationNormalizer: + autowire: true diff --git a/config/services/providers.yml b/config/services/providers.yml new file mode 100644 index 0000000..7fbc465 --- /dev/null +++ b/config/services/providers.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Service\Provider\SubscriberListProvider: + autowire: true + autoconfigure: true diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index e971388..1a277b5 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -10,7 +10,9 @@ use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; use PhpList\RestBundle\Serializer\SubscriberListNormalizer; +use PhpList\RestBundle\Service\Factory\PaginationCursorRequestFactory; use PhpList\RestBundle\Service\Manager\SubscriberListManager; +use PhpList\RestBundle\Service\Provider\SubscriberListProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -35,17 +37,23 @@ class ListController extends AbstractController private SubscriberListNormalizer $normalizer; private SubscriberListManager $subscriberListManager; private RequestValidator $validator; + private PaginationCursorRequestFactory $paginationFactory; + private SubscriberListProvider $subscriberListProvider; public function __construct( Authentication $authentication, SubscriberListNormalizer $normalizer, RequestValidator $validator, - SubscriberListManager $subscriberListManager + SubscriberListManager $subscriberListManager, + PaginationCursorRequestFactory $paginationFactory, + SubscriberListProvider $subscriberListProvider ) { $this->authentication = $authentication; $this->normalizer = $normalizer; $this->validator = $validator; $this->subscriberListManager = $subscriberListManager; + $this->paginationFactory = $paginationFactory; + $this->subscriberListProvider = $subscriberListProvider; } #[Route('', name: 'get_lists', methods: ['GET'])] @@ -63,6 +71,20 @@ public function __construct( schema: new OA\Schema( type: 'string' ) + ), + new OA\Parameter( + name: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) ) ], responses: [ @@ -70,29 +92,15 @@ public function __construct( response: 200, description: 'Success', content: new OA\JsonContent( - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'name', type: 'string', example: 'News'), - new OA\Property( - property: 'description', - type: 'string', - example: 'News (and some fun stuff)' - ), - new OA\Property( - property: 'creation_date', - type: 'string', - format: 'date-time', - example: '2016-06-22T15:01:17+00:00' - ), - new OA\Property(property: 'list_position', type: 'integer', example: 12), - new OA\Property(property: 'subject_prefix', type: 'string', example: 'phpList'), - new OA\Property(property: 'public', type: 'boolean', example: true), - new OA\Property(property: 'category', type: 'string', example: 'news'), - new OA\Property(property: 'id', type: 'integer', example: 1) - ], - type: 'object' - ) + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscriberList') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' ) ), new OA\Response( @@ -105,13 +113,12 @@ public function __construct( public function getLists(Request $request): JsonResponse { $this->requireAuthentication($request); - $data = $this->subscriberListManager->getAll(); - - $normalized = array_map(function ($item) { - return $this->normalizer->normalize($item); - }, $data); + $pagination = $this->paginationFactory->fromRequest($request); - return new JsonResponse($normalized, Response::HTTP_OK); + return new JsonResponse( + $this->subscriberListProvider->getPaginatedList($pagination), + Response::HTTP_OK + ); } #[Route('/{listId}', name: 'get_list', methods: ['GET'])] @@ -140,19 +147,7 @@ public function getLists(Request $request): JsonResponse new OA\Response( response: 200, description: 'Success', - content: new OA\JsonContent( - type: 'object', - example: [ - 'name' => 'News', - 'description' => 'News (and some fun stuff)', - 'creation_date' => '2016-06-22T15:01:17+00:00', - 'list_position' => 12, - 'subject_prefix' => 'phpList', - 'public' => true, - 'category' => 'news', - 'id' => 1 - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/SubscriberList') ), new OA\Response( response: 403, @@ -276,19 +271,7 @@ public function deleteList( new OA\Response( response: 201, description: 'Success', - content: new OA\JsonContent( - type: 'object', - example: [ - 'name' => 'News', - 'description' => 'News (and some fun stuff)', - 'creation_date' => '2016-06-22T15:01:17+00:00', - 'list_position' => 12, - 'subject_prefix' => 'phpList', - 'public' => true, - 'category' => 'news', - 'id' => 1 - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/SubscriberList') ), new OA\Response( response: 403, diff --git a/src/Entity/Dto/CursorPaginationResult.php b/src/Entity/Dto/CursorPaginationResult.php new file mode 100644 index 0000000..0302f28 --- /dev/null +++ b/src/Entity/Dto/CursorPaginationResult.php @@ -0,0 +1,15 @@ +afterId = $afterId; + $this->limit = min(100, max(1, $limit)); + } + + public static function fromRequest(Request $request): self + { + return new self( + $request->query->get('after_id') ? (int)$request->query->get('after_id') : 0, + $request->query->getInt('limit', 25) + ); + } +} diff --git a/src/OpenApi/SwaggerSchemasResponse.php b/src/OpenApi/SwaggerSchemasResponse.php index 4701ada..efe9c5d 100644 --- a/src/OpenApi/SwaggerSchemasResponse.php +++ b/src/OpenApi/SwaggerSchemasResponse.php @@ -62,6 +62,17 @@ ], type: 'object' )] + +#[OA\Schema( + schema: 'CursorPagination', + properties: [ + new OA\Property(property: 'total', type: 'integer', example: 100), + new OA\Property(property: 'limit', type: 'integer', example: 25), + new OA\Property(property: 'has_more', type: 'boolean', example: true), + new OA\Property(property: 'next_cursor', type: 'integer', example: 129) + ], + type: 'object' +)] class SwaggerSchemasResponse { } diff --git a/src/OpenApi/SwaggerSchemasResponseEntity.php b/src/OpenApi/SwaggerSchemasResponseEntity.php index 920260d..faabf0d 100644 --- a/src/OpenApi/SwaggerSchemasResponseEntity.php +++ b/src/OpenApi/SwaggerSchemasResponseEntity.php @@ -13,7 +13,7 @@ new OA\Property(property: 'name', type: 'string', example: 'Newsletter'), new OA\Property(property: 'description', type: 'string', example: 'Monthly updates'), new OA\Property( - property: 'creation_date', + property: 'created_at', type: 'string', format: 'date-time', example: '2022-12-01T10:00:00Z' @@ -28,7 +28,7 @@ new OA\Property(property: 'id', type: 'integer', example: 1), new OA\Property(property: 'email', type: 'string', example: 'subscriber@example.com'), new OA\Property( - property: 'creation_date', + property: 'created_at', type: 'string', format: 'date-time', example: '2023-01-01T12:00:00Z', diff --git a/src/Serializer/CursorPaginationNormalizer.php b/src/Serializer/CursorPaginationNormalizer.php new file mode 100644 index 0000000..d8fa512 --- /dev/null +++ b/src/Serializer/CursorPaginationNormalizer.php @@ -0,0 +1,41 @@ +items; + $limit = $object->limit; + $total = $object->total; + $hasNext = !empty($items) && isset($items[array_key_last($items)]['id']); + + return [ + 'items' => $items, + 'pagination' => [ + 'total' => $total, + 'limit' => $limit, + 'has_more' => count($items) === $limit, + 'next_cursor' => $hasNext ? $items[array_key_last($items)]['id'] : null, + ], + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof CursorPaginationResult; + } +} diff --git a/src/Serializer/SubscriberListNormalizer.php b/src/Serializer/SubscriberListNormalizer.php index 406ff62..3bf93ec 100644 --- a/src/Serializer/SubscriberListNormalizer.php +++ b/src/Serializer/SubscriberListNormalizer.php @@ -21,7 +21,7 @@ public function normalize($object, string $format = null, array $context = []): return [ 'id' => $object->getId(), 'name' => $object->getName(), - 'creation_date' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 'description' => $object->getDescription(), 'list_position' => $object->getListPosition(), 'subject_prefix' => $object->getSubjectPrefix(), diff --git a/src/Service/Factory/PaginationCursorRequestFactory.php b/src/Service/Factory/PaginationCursorRequestFactory.php new file mode 100644 index 0000000..28b561e --- /dev/null +++ b/src/Service/Factory/PaginationCursorRequestFactory.php @@ -0,0 +1,19 @@ +query->getInt('after_id'), + $request->query->getInt('limit', 25) + ); + } +} diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php index c8d72ad..7e8346e 100644 --- a/src/Service/Manager/SubscriberListManager.php +++ b/src/Service/Manager/SubscriberListManager.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; +use PhpList\RestBundle\Entity\Request\PaginationCursorRequest; class SubscriberListManager { @@ -34,10 +35,17 @@ public function createSubscriberList( return $subscriberList; } - /** @return SubscriberList[] */ - public function getAll(): array + /** + * @return SubscriberList[] + */ + public function getPaginated(PaginationCursorRequest $pagination): array { - return $this->subscriberListRepository->findAll(); + return $this->subscriberListRepository->getAfterId($pagination->afterId, $pagination->limit); + } + + public function getTotalCount(): int + { + return $this->subscriberListRepository->count(); } public function delete(SubscriberList $subscriberList): void diff --git a/src/Service/Provider/SubscriberListProvider.php b/src/Service/Provider/SubscriberListProvider.php new file mode 100644 index 0000000..ef21d83 --- /dev/null +++ b/src/Service/Provider/SubscriberListProvider.php @@ -0,0 +1,33 @@ +subscriberListManager->getPaginated($pagination); + $total = $this->subscriberListManager->getTotalCount(); + + $normalized = array_map(fn($item) => $this->normalizer->normalize($item), $lists); + + return $this->paginationNormalizer->normalize( + new CursorPaginationResult($normalized, $pagination->limit, $total) + ); + } +} diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index 0fb9e9a..dd0ba65 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -60,12 +60,12 @@ public function testGetListsWithCurrentSessionKeyReturnsListData() $this->authenticatedJsonRequest('get', '/api/v2/lists'); - $this->assertJsonResponseContentEquals( - [ + $this->assertJsonResponseContentEquals([ + 'items' => [ [ 'id' => 1, 'name' => 'News', - 'creation_date' => '2016-06-22T15:01:17+00:00', + 'created_at' => '2016-06-22T15:01:17+00:00', 'description' => 'News (and some fun stuff)', 'list_position' => 12, 'subject_prefix' => 'phpList', @@ -75,7 +75,7 @@ public function testGetListsWithCurrentSessionKeyReturnsListData() [ 'id' => 2, 'name' => 'More news', - 'creation_date' => '2016-06-22T15:01:17+00:00', + 'created_at' => '2016-06-22T15:01:17+00:00', 'description' => '', 'list_position' => 12, 'subject_prefix' => '', @@ -85,15 +85,21 @@ public function testGetListsWithCurrentSessionKeyReturnsListData() [ 'id' => 3, 'name' => 'Tech news', - 'creation_date' => '2019-02-11T15:01:15+00:00', + 'created_at' => '2019-02-11T15:01:15+00:00', 'description' => '', 'list_position' => 12, 'subject_prefix' => '', 'public' => true, 'category' => '', ], - ] - ); + ], + 'pagination' => [ + 'total' => 3, + 'limit' => 25, + 'has_more' => false, + 'next_cursor' => 3, + ], + ]); } public function testGetListWithoutSessionKeyForExistingListReturnsForbiddenStatus() @@ -131,7 +137,7 @@ public function testGetListWithCurrentSessionKeyReturnsListData() [ 'id' => 1, 'name' => 'News', - 'creation_date' => '2016-06-22T15:01:17+00:00', + 'created_at' => '2016-06-22T15:01:17+00:00', 'description' => 'News (and some fun stuff)', 'list_position' => 12, 'subject_prefix' => 'phpList', diff --git a/tests/Unit/Serializer/SubscriberListNormalizerTest.php b/tests/Unit/Serializer/SubscriberListNormalizerTest.php index a3efa12..aafde29 100644 --- a/tests/Unit/Serializer/SubscriberListNormalizerTest.php +++ b/tests/Unit/Serializer/SubscriberListNormalizerTest.php @@ -38,7 +38,7 @@ public function testNormalize(): void $this->assertSame([ 'id' => 101, 'name' => 'Tech News', - 'creation_date' => '2025-04-01T10:00:00+00:00', + 'created_at' => '2025-04-01T10:00:00+00:00', 'description' => 'All tech updates', 'list_position' => 2, 'subject_prefix' => 'tech', diff --git a/tests/Unit/Service/Manager/SubscriberListManagerTest.php b/tests/Unit/Service/Manager/SubscriberListManagerTest.php index 7659853..20e2d77 100644 --- a/tests/Unit/Service/Manager/SubscriberListManagerTest.php +++ b/tests/Unit/Service/Manager/SubscriberListManagerTest.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Domain\Repository\Subscription\SubscriberListRepository; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; +use PhpList\RestBundle\Entity\Request\PaginationCursorRequest; use PhpList\RestBundle\Service\Manager\SubscriberListManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -47,15 +48,15 @@ public function testCreateSubscriberList(): void $this->assertSame($admin, $result->getOwner()); } - public function testGetAll(): void + public function testGetPaginated(): void { $list = new SubscriberList(); $this->subscriberListRepository ->expects($this->once()) - ->method('findAll') + ->method('getAfterId') ->willReturn([$list]); - $result = $this->manager->getAll(); + $result = $this->manager->getPaginated(new PaginationCursorRequest(0)); $this->assertIsArray($result); $this->assertCount(1, $result); From a2e98fd426d4507e1b063774bbbe68be8297866f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 3 May 2025 18:37:42 +0400 Subject: [PATCH 57/62] ISSUE-345: refactor validation logic --- config/services/providers.yml | 4 + config/services/validators.yml | 5 + src/Controller/AdministratorController.php | 118 ++++++++++++++---- src/Controller/BaseController.php | 40 ++++++ src/Controller/CampaignController.php | 28 +---- src/Controller/ListController.php | 10 +- src/Controller/SessionController.php | 19 ++- src/Controller/SubscriberController.php | 47 +++---- src/Controller/SubscriptionController.php | 29 ++--- src/Controller/TemplateController.php | 10 +- src/Controller/Traits/AuthenticationTrait.php | 42 ------- .../Request/CreateAdministratorRequest.php | 6 +- .../Request/CreateSubscriberRequest.php | 3 +- .../Request/UpdateAdministratorRequest.php | 6 + src/Entity/Request/UpdateMessageRequest.php | 26 ---- .../Request/UpdateSubscriberRequest.php | 3 +- src/Service/Builder/MessageBuilder.php | 2 +- .../Provider/AdministratorProvider.php | 36 ++++++ src/Validator/Constraint/UniqueEmail.php | 15 ++- .../Constraint/UniqueEmailValidator.php | 13 +- src/Validator/Constraint/UniqueLoginName.php | 22 ++++ .../Constraint/UniqueLoginNameValidator.php | 46 +++++++ src/Validator/RequestValidator.php | 33 ++++- .../Constraint/UniqueEmailValidatorTest.php | 53 +++++--- tests/Unit/Validator/RequestValidatorTest.php | 63 +++++++--- 25 files changed, 441 insertions(+), 238 deletions(-) create mode 100644 src/Controller/BaseController.php delete mode 100644 src/Controller/Traits/AuthenticationTrait.php create mode 100644 src/Service/Provider/AdministratorProvider.php create mode 100644 src/Validator/Constraint/UniqueLoginName.php create mode 100644 src/Validator/Constraint/UniqueLoginNameValidator.php diff --git a/config/services/providers.yml b/config/services/providers.yml index 7fbc465..47b380f 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -7,3 +7,7 @@ services: PhpList\RestBundle\Service\Provider\SubscriberListProvider: autowire: true autoconfigure: true + + PhpList\RestBundle\Service\Provider\AdministratorProvider: + autowire: true + autoconfigure: true diff --git a/config/services/validators.yml b/config/services/validators.yml index 481556c..d1d519c 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -29,3 +29,8 @@ services: PhpList\RestBundle\Validator\Constraint\ContainsPlaceholderValidator: tags: ['validator.constraint_validator'] + + PhpList\RestBundle\Validator\Constraint\UniqueLoginNameValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] diff --git a/src/Controller/AdministratorController.php b/src/Controller/AdministratorController.php index 277b2cf..127032f 100644 --- a/src/Controller/AdministratorController.php +++ b/src/Controller/AdministratorController.php @@ -6,32 +6,104 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Identity\Administrator; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; +use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Entity\Request\CreateAdministratorRequest; use PhpList\RestBundle\Entity\Request\UpdateAdministratorRequest; use PhpList\RestBundle\Serializer\AdministratorNormalizer; use PhpList\RestBundle\Service\Manager\AdministratorManager; +use PhpList\RestBundle\Service\Provider\AdministratorProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** * This controller provides CRUD operations for Administrator entities. */ #[Route('/administrators')] -class AdministratorController extends AbstractController +class AdministratorController extends BaseController { - use AuthenticationTrait; - private AdministratorManager $administratorManager; + private AdministratorNormalizer $normalizer; + private AdministratorProvider $administratorProvider; - public function __construct(AdministratorManager $administratorManager) - { + public function __construct( + Authentication $authentication, + AdministratorManager $administratorManager, + AdministratorNormalizer $normalizer, + RequestValidator $validator, + AdministratorProvider $administratorProvider + ) { + parent::__construct($authentication, $validator); $this->administratorManager = $administratorManager; + $this->normalizer = $normalizer; + $this->administratorProvider = $administratorProvider; + } + + #[Route('', name: 'get_administrators', methods: ['GET'])] + #[OA\Post( + path: '/administrators', + description: 'Get list of administrators.', + summary: 'Get Administrators', + tags: ['administrators'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Administrator') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' + ) + ), + new OA\Response( + response: 400, + description: 'Invalid input' + ) + ] + )] + public function getAdministrators(Request $request): JsonResponse + { + $this->requireAuthentication($request); + + return new JsonResponse( + $this->administratorProvider->getPaginatedList($request), + Response::HTTP_OK + ); } #[Route('', name: 'create_administrator', methods: ['POST'])] @@ -49,7 +121,7 @@ public function __construct(AdministratorManager $administratorManager) new OA\Response( response: 201, description: 'Administrator created successfully', - content: new OA\JsonContent(ref: '#/components/schemas/CreateAdministratorRequest') + content: new OA\JsonContent(ref: '#/components/schemas/Administrator') ), new OA\Response( response: 400, @@ -62,11 +134,11 @@ public function createAdministrator( RequestValidator $validator, AdministratorNormalizer $normalizer ): JsonResponse { + $this->requireAuthentication($request); + /** @var CreateAdministratorRequest $dto */ $dto = $validator->validate($request, CreateAdministratorRequest::class); - $administrator = $this->administratorManager->createAdministrator($dto); - $json = $normalizer->normalize($administrator, 'json'); return new JsonResponse($json, Response::HTTP_CREATED); @@ -100,16 +172,17 @@ public function createAdministrator( ] )] public function getAdministrator( + Request $request, #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator, - AdministratorNormalizer $normalizer ): JsonResponse { + $this->requireAuthentication($request); + if (!$administrator) { - return new JsonResponse(['message' => 'Administrator not found.'], Response::HTTP_NOT_FOUND); + throw new NotFoundHttpException('Administrator not found.'); } + $json = $this->normalizer->normalize($administrator, 'json'); - $json = $normalizer->normalize($administrator, 'json'); - - return new JsonResponse($json); + return new JsonResponse($json, Response::HTTP_OK); } #[Route('/{administratorId}', name: 'update_administrator', methods: ['PUT'])] @@ -146,15 +219,14 @@ public function getAdministrator( public function updateAdministrator( Request $request, #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator, - RequestValidator $validator ): JsonResponse { + $this->requireAuthentication($request); + if (!$administrator) { - return new JsonResponse(['message' => 'Administrator not found.'], Response::HTTP_NOT_FOUND); + throw new NotFoundHttpException('Administrator not found.'); } - /** @var UpdateAdministratorRequest $dto */ - $dto = $validator->validate($request, UpdateAdministratorRequest::class); - + $dto = $this->validator->validate($request, UpdateAdministratorRequest::class); $this->administratorManager->updateAdministrator($administrator, $dto); return new JsonResponse(null, Response::HTTP_OK); @@ -187,12 +259,14 @@ public function updateAdministrator( ] )] public function deleteAdministrator( + Request $request, #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator ): JsonResponse { + $this->requireAuthentication($request); + if (!$administrator) { - return new JsonResponse(['message' => 'Administrator not found.'], Response::HTTP_NOT_FOUND); + throw new NotFoundHttpException('Administrator not found.'); } - $this->administratorManager->deleteAdministrator($administrator); return new JsonResponse(null, Response::HTTP_NO_CONTENT); diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php new file mode 100644 index 0000000..6b84672 --- /dev/null +++ b/src/Controller/BaseController.php @@ -0,0 +1,40 @@ +authentication = $authentication; + $this->validator = $validator; + } + + protected function requireAuthentication(Request $request): Administrator + { + $administrator = $this->authentication->authenticateByApiKey($request); + if ($administrator === null) { + throw new AccessDeniedHttpException( + 'No valid session key was provided as basic auth password.', + null, + 1512749701 + ); + } + + return $administrator; + } +} diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 2db931c..0e78776 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -7,21 +7,17 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Messaging\Message; use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateMessageRequest; use PhpList\RestBundle\Entity\Request\UpdateMessageRequest; use PhpList\RestBundle\Serializer\MessageNormalizer; use PhpList\RestBundle\Service\Manager\MessageManager; -use PhpList\RestBundle\Service\Provider\MessageProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; /** * This controller provides REST API to manage campaigns. @@ -29,11 +25,8 @@ * @author Tatevik Grigoryan */ #[Route('/campaigns')] -class CampaignController extends AbstractController +class CampaignController extends BaseController { - use AuthenticationTrait; - - private RequestValidator $validator; private MessageNormalizer $normalizer; private MessageManager $messageManager; @@ -43,8 +36,7 @@ public function __construct( MessageNormalizer $normalizer, MessageManager $messageManager ) { - $this->authentication = $authentication; - $this->validator = $validator; + parent::__construct($authentication, $validator); $this->normalizer = $normalizer; $this->messageManager = $messageManager; } @@ -265,7 +257,6 @@ public function createMessage(Request $request, MessageNormalizer $normalizer): )] public function updateMessage( Request $request, - SerializerInterface $serializer, #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, ): JsonResponse { $authUser = $this->requireAuthentication($request); @@ -273,18 +264,11 @@ public function updateMessage( if (!$message) { throw new NotFoundHttpException('Campaign not found.'); } + /** @var UpdateMessageRequest $updateMessageRequest */ + $updateMessageRequest = $this->validator->validate($request, UpdateMessageRequest::class); + $data = $this->messageManager->updateMessage($updateMessageRequest, $message, $authUser); - /** @return UpdateMessageRequest $updateMessageRequest */ - $updateMessageRequest = $serializer->deserialize($request->getContent(), UpdateMessageRequest::class, 'json'); - $updateMessageRequest->messageId = $message->getId(); - $this->validator->validateDto($updateMessageRequest); - - return new JsonResponse( - $this->normalizer->normalize( - $this->messageManager->updateMessage($updateMessageRequest, $message, $authUser) - ), - Response::HTTP_OK - ); + return new JsonResponse($this->normalizer->normalize($data), Response::HTTP_OK); } #[Route('/{messageId}', name: 'delete_campaign', methods: ['DELETE'])] diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 1a277b5..18dcd3a 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -7,7 +7,6 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; use PhpList\RestBundle\Serializer\SubscriberListNormalizer; use PhpList\RestBundle\Service\Factory\PaginationCursorRequestFactory; @@ -15,7 +14,6 @@ use PhpList\RestBundle\Service\Provider\SubscriberListProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -30,13 +28,10 @@ * @author Tatevik Grigoryan */ #[Route('/lists')] -class ListController extends AbstractController +class ListController extends BaseController { - use AuthenticationTrait; - private SubscriberListNormalizer $normalizer; private SubscriberListManager $subscriberListManager; - private RequestValidator $validator; private PaginationCursorRequestFactory $paginationFactory; private SubscriberListProvider $subscriberListProvider; @@ -48,9 +43,8 @@ public function __construct( PaginationCursorRequestFactory $paginationFactory, SubscriberListProvider $subscriberListProvider ) { - $this->authentication = $authentication; + parent::__construct($authentication, $validator); $this->normalizer = $normalizer; - $this->validator = $validator; $this->subscriberListManager = $subscriberListManager; $this->paginationFactory = $paginationFactory; $this->subscriberListProvider = $subscriberListProvider; diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index 9e360d2..e248a9c 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -7,13 +7,11 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Identity\AdministratorToken; use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateSessionRequest; use PhpList\RestBundle\Serializer\AdministratorTokenNormalizer; use PhpList\RestBundle\Service\Manager\SessionManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -28,15 +26,17 @@ * @author Tatevik Grigoryan */ #[Route('/sessions')] -class SessionController extends AbstractController +class SessionController extends BaseController { - use AuthenticationTrait; - private SessionManager $sessionManager; - public function __construct(Authentication $authentication, SessionManager $sessionManager) - { - $this->authentication = $authentication; + public function __construct( + Authentication $authentication, + SessionManager $sessionManager, + RequestValidator $validator + ) { + parent::__construct($authentication, $validator); + $this->sessionManager = $sessionManager; } @@ -87,11 +87,10 @@ public function __construct(Authentication $authentication, SessionManager $sess )] public function createSession( Request $request, - RequestValidator $validator, AdministratorTokenNormalizer $normalizer ): JsonResponse { /** @var CreateSessionRequest $createSessionRequest */ - $createSessionRequest = $validator->validate($request, CreateSessionRequest::class); + $createSessionRequest = $this->validator->validate($request, CreateSessionRequest::class); $token = $this->sessionManager->createSession($createSessionRequest); $json = $normalizer->normalize($token, 'json'); diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index af46195..65c4eee 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -7,20 +7,17 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateSubscriberRequest; use PhpList\RestBundle\Entity\Request\UpdateSubscriberRequest; use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Service\Manager\SubscriberManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; /** * This controller provides REST API access to subscribers. @@ -29,16 +26,21 @@ * @author Tatevik Grigoryan */ #[Route('/subscribers')] -class SubscriberController extends AbstractController +class SubscriberController extends BaseController { - use AuthenticationTrait; - private SubscriberManager $subscriberManager; + private SubscriberNormalizer $subscriberNormalizer; - public function __construct(Authentication $authentication, SubscriberManager $subscriberManager) - { + public function __construct( + Authentication $authentication, + SubscriberManager $subscriberManager, + RequestValidator $validator, + SubscriberNormalizer $subscriberNormalizer, + ) { + parent::__construct($authentication, $validator); $this->authentication = $authentication; $this->subscriberManager = $subscriberManager; + $this->subscriberNormalizer = $subscriberNormalizer; } #[Route('', name: 'create_subscriber', methods: ['POST'])] @@ -91,22 +93,17 @@ public function __construct(Authentication $authentication, SubscriberManager $s ), ] )] - public function createSubscriber( - Request $request, - SerializerInterface $serializer, - RequestValidator $validator - ): JsonResponse { + public function createSubscriber(Request $request): JsonResponse + { $this->requireAuthentication($request); /** @var CreateSubscriberRequest $subscriberRequest */ - $subscriberRequest = $validator->validate($request, CreateSubscriberRequest::class); + $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest); return new JsonResponse( - $serializer->serialize($subscriber, 'json'), - Response::HTTP_CREATED, - [], - true + $this->subscriberNormalizer->normalize($subscriber, 'json'), + Response::HTTP_CREATED ); } @@ -172,9 +169,6 @@ public function createSubscriber( )] public function updateSubscriber( Request $request, - SerializerInterface $serializer, - RequestValidator $validator, - SubscriberNormalizer $subscriberNormalizer, #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, ): JsonResponse { $this->requireAuthentication($request); @@ -182,14 +176,11 @@ public function updateSubscriber( if (!$subscriber) { throw new NotFoundHttpException('Subscriber not found.'); } - /** @var UpdateSubscriberRequest $dto */ - $dto = $serializer->deserialize($request->getContent(), UpdateSubscriberRequest::class, 'json'); - $dto->subscriberId = $subscriber->getId(); - $validator->validateDto($dto); + $dto = $this->validator->validate($request, UpdateSubscriberRequest::class); $subscriber = $this->subscriberManager->updateSubscriber($dto); - return new JsonResponse($subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_OK); + return new JsonResponse($this->subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_OK); } #[Route('/{subscriberId}', name: 'get_subscriber_by_id', methods: ['GET'])] @@ -232,13 +223,13 @@ public function updateSubscriber( ) ] )] - public function getSubscriber(Request $request, int $subscriberId, SubscriberNormalizer $serializer): JsonResponse + public function getSubscriber(Request $request, int $subscriberId): JsonResponse { $this->requireAuthentication($request); $subscriber = $this->subscriberManager->getSubscriber($subscriberId); - return new JsonResponse($serializer->normalize($subscriber), Response::HTTP_OK); + return new JsonResponse($this->subscriberNormalizer->normalize($subscriber), Response::HTTP_OK); } #[Route('/{subscriberId}', name: 'delete_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])] diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index fdf56b2..a2a7aa7 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -7,14 +7,12 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\SubscriptionRequest; use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Serializer\SubscriptionNormalizer; use PhpList\RestBundle\Service\Manager\SubscriptionManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -27,21 +25,23 @@ * @author Tatevik Grigoryan */ #[Route('/lists')] -class SubscriptionController extends AbstractController +class SubscriptionController extends BaseController { - use AuthenticationTrait; - private SubscriptionManager $subscriptionManager; - private RequestValidator $validator; + private SubscriberNormalizer $subscriberNormalizer; + private SubscriptionNormalizer $subscriptionNormalizer; public function __construct( Authentication $authentication, SubscriptionManager $subscriptionManager, - RequestValidator $validator + RequestValidator $validator, + SubscriberNormalizer $subscriberNormalizer, + SubscriptionNormalizer $subscriptionNormalizer, ) { - $this->authentication = $authentication; + parent::__construct($authentication, $validator); $this->subscriptionManager = $subscriptionManager; - $this->validator = $validator; + $this->subscriberNormalizer = $subscriberNormalizer; + $this->subscriptionNormalizer = $subscriptionNormalizer; } #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] @@ -89,7 +89,6 @@ public function __construct( )] public function getListMembers( Request $request, - SubscriberNormalizer $normalizer, #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, ): JsonResponse { $this->requireAuthentication($request); @@ -99,9 +98,7 @@ public function getListMembers( } $subscribers = $this->subscriptionManager->getSubscriberListMembers($list); - $normalized = array_map(function ($item) use ($normalizer) { - return $normalizer->normalize($item); - }, $subscribers); + $normalized = array_map(fn($subscriber) => $this->subscriberNormalizer->normalize($subscriber), $subscribers); return new JsonResponse($normalized, Response::HTTP_OK); } @@ -238,7 +235,6 @@ public function getSubscribersCount( )] public function createSubscription( Request $request, - SubscriptionNormalizer $serializer, #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, ): JsonResponse { $this->requireAuthentication($request); @@ -250,10 +246,7 @@ public function createSubscription( /** @var SubscriptionRequest $subscriptionRequest */ $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); - - $normalized = array_map(function ($item) use ($serializer) { - return $serializer->normalize($item); - }, $subscriptions); + $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); return new JsonResponse($normalized, Response::HTTP_CREATED); } diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index f731788..b294f6f 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -8,13 +8,11 @@ use PhpList\Core\Domain\Model\Messaging\Template; use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use PhpList\Core\Security\Authentication; -use PhpList\RestBundle\Controller\Traits\AuthenticationTrait; use PhpList\RestBundle\Entity\Request\CreateTemplateRequest; use PhpList\RestBundle\Serializer\TemplateNormalizer; use PhpList\RestBundle\Service\Manager\TemplateManager; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -27,13 +25,10 @@ * @author Tatevik Grigoryan */ #[Route('/templates')] -class TemplateController extends AbstractController +class TemplateController extends BaseController { - use AuthenticationTrait; - private TemplateRepository $templateRepository; private TemplateNormalizer $normalizer; - private RequestValidator $validator; private TemplateManager $templateManager; public function __construct( @@ -43,10 +38,9 @@ public function __construct( RequestValidator $validator, TemplateManager $templateManager ) { - $this->authentication = $authentication; + parent::__construct($authentication, $validator); $this->templateRepository = $templateRepository; $this->normalizer = $normalizer; - $this->validator = $validator; $this->templateManager = $templateManager; } diff --git a/src/Controller/Traits/AuthenticationTrait.php b/src/Controller/Traits/AuthenticationTrait.php deleted file mode 100644 index 6df8261..0000000 --- a/src/Controller/Traits/AuthenticationTrait.php +++ /dev/null @@ -1,42 +0,0 @@ - - * @author Tatevik Grigoryan - */ -trait AuthenticationTrait -{ - private ?Authentication $authentication = null; - - /** - * Checks for valid authentication in the given request and throws an exception if there is none. - * - * @throws AccessDeniedHttpException - */ - private function requireAuthentication(Request $request): Administrator - { - $administrator = $this->authentication->authenticateByApiKey($request); - if ($administrator === null) { - throw new AccessDeniedHttpException( - 'No valid session key was provided as basic auth password.', - null, - 1512749701 - ); - } - - return $administrator; - } -} diff --git a/src/Entity/Request/CreateAdministratorRequest.php b/src/Entity/Request/CreateAdministratorRequest.php index 03ef371..71bf9d6 100644 --- a/src/Entity/Request/CreateAdministratorRequest.php +++ b/src/Entity/Request/CreateAdministratorRequest.php @@ -4,12 +4,15 @@ namespace PhpList\RestBundle\Entity\Request; +use PhpList\Core\Domain\Model\Identity\Administrator; use Symfony\Component\Validator\Constraints as Assert; +use PhpList\RestBundle\Validator\Constraint as CustomAssert; -class CreateAdministratorRequest +class CreateAdministratorRequest implements RequestInterface { #[Assert\NotBlank] #[Assert\Length(min: 3, max: 255)] + #[CustomAssert\UniqueLoginName] public string $loginName; #[Assert\NotBlank] @@ -18,6 +21,7 @@ class CreateAdministratorRequest #[Assert\NotBlank] #[Assert\Email] + #[CustomAssert\UniqueEmail(Administrator::class)] public string $email; #[Assert\NotNull] diff --git a/src/Entity/Request/CreateSubscriberRequest.php b/src/Entity/Request/CreateSubscriberRequest.php index f3317f8..87f5b9e 100644 --- a/src/Entity/Request/CreateSubscriberRequest.php +++ b/src/Entity/Request/CreateSubscriberRequest.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Entity\Request; +use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\RestBundle\Validator\Constraint as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; @@ -11,7 +12,7 @@ class CreateSubscriberRequest implements RequestInterface { #[Assert\NotBlank] #[Assert\Email] - #[CustomAssert\UniqueEmail] + #[CustomAssert\UniqueEmail(Subscriber::class)] public string $email; #[Assert\Type(type: 'bool')] diff --git a/src/Entity/Request/UpdateAdministratorRequest.php b/src/Entity/Request/UpdateAdministratorRequest.php index 6d1d4df..52310ce 100644 --- a/src/Entity/Request/UpdateAdministratorRequest.php +++ b/src/Entity/Request/UpdateAdministratorRequest.php @@ -4,17 +4,23 @@ namespace PhpList\RestBundle\Entity\Request; +use PhpList\Core\Domain\Model\Identity\Administrator; use Symfony\Component\Validator\Constraints as Assert; +use PhpList\RestBundle\Validator\Constraint as CustomAssert; class UpdateAdministratorRequest { + public int $administratorId; + #[Assert\Length(min: 3, max: 255)] + #[CustomAssert\UniqueLoginName] public ?string $loginName = null; #[Assert\Length(min: 6, max: 255)] public ?string $password = null; #[Assert\Email] + #[CustomAssert\UniqueEmail(Administrator::class)] public ?string $email = null; #[Assert\Type('bool')] diff --git a/src/Entity/Request/UpdateMessageRequest.php b/src/Entity/Request/UpdateMessageRequest.php index 66539dd..eb7bd44 100644 --- a/src/Entity/Request/UpdateMessageRequest.php +++ b/src/Entity/Request/UpdateMessageRequest.php @@ -4,33 +4,7 @@ namespace PhpList\RestBundle\Entity\Request; -use PhpList\RestBundle\Entity\Request\Message\MessageContentRequest; -use PhpList\RestBundle\Entity\Request\Message\MessageFormatRequest; -use PhpList\RestBundle\Entity\Request\Message\MessageOptionsRequest; -use PhpList\RestBundle\Entity\Request\Message\MessageScheduleRequest; -use PhpList\RestBundle\Validator\Constraint as CustomAssert; -use Symfony\Component\Validator\Constraints as Assert; - class UpdateMessageRequest extends CreateMessageRequest { public int $messageId; - - #[Assert\Valid] - #[Assert\NotNull] - public MessageContentRequest $content; - - #[Assert\Valid] - #[Assert\NotNull] - public MessageFormatRequest $format; - - #[Assert\Valid] - #[Assert\NotNull] - public MessageScheduleRequest $schedule; - - #[Assert\Valid] - #[Assert\NotNull] - public MessageOptionsRequest $options; - - #[CustomAssert\TemplateExists] - public ?int $templateId; } diff --git a/src/Entity/Request/UpdateSubscriberRequest.php b/src/Entity/Request/UpdateSubscriberRequest.php index 5902a13..b74ebd2 100644 --- a/src/Entity/Request/UpdateSubscriberRequest.php +++ b/src/Entity/Request/UpdateSubscriberRequest.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Entity\Request; +use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\RestBundle\Validator\Constraint as CustomAssert; use Symfony\Component\Validator\Constraints as Assert; @@ -13,7 +14,7 @@ class UpdateSubscriberRequest implements RequestInterface #[Assert\NotBlank] #[Assert\Email] - #[CustomAssert\UniqueEmail] + #[CustomAssert\UniqueEmail(entityClass: Subscriber::class)] public string $email; #[Assert\Type(type: 'bool')] diff --git a/src/Service/Builder/MessageBuilder.php b/src/Service/Builder/MessageBuilder.php index f547610..138215f 100644 --- a/src/Service/Builder/MessageBuilder.php +++ b/src/Service/Builder/MessageBuilder.php @@ -36,7 +36,7 @@ public function buildFromRequest(RequestInterface $request, object $context = nu $content = $this->messageContentBuilder->buildFromDto($request->content); $options = $this->messageOptionsBuilder->buildFromDto($request->options); $template = null; - if ($request->templateId > 0) { + if (isset($request->templateId)) { $template = $this->templateRepository->find($request->templateId); } diff --git a/src/Service/Provider/AdministratorProvider.php b/src/Service/Provider/AdministratorProvider.php new file mode 100644 index 0000000..6afc330 --- /dev/null +++ b/src/Service/Provider/AdministratorProvider.php @@ -0,0 +1,36 @@ +paginationFactory->fromRequest($request); + $lists = $this->administratorRepository->getAfterId($pagination->afterId, $pagination->limit); + $total = $this->administratorRepository->count(); + + $normalized = array_map(fn($item) => $this->normalizer->normalize($item), $lists); + + return $this->paginationNormalizer->normalize( + new CursorPaginationResult($normalized, $pagination->limit, $total) + ); + } +} diff --git a/src/Validator/Constraint/UniqueEmail.php b/src/Validator/Constraint/UniqueEmail.php index e3f6aa0..72152bc 100644 --- a/src/Validator/Constraint/UniqueEmail.php +++ b/src/Validator/Constraint/UniqueEmail.php @@ -9,14 +9,17 @@ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class UniqueEmail extends Constraint { - public string $message = 'The email "{{ value }}" is already taken.'; - public string $mode = 'strict'; + public string $message = 'This email is already in use.'; + public string $entityClass; - public function __construct(?string $mode = null, ?string $message = null, ?array $groups = null, $payload = null) + public function __construct(string $entityClass) { - parent::__construct([], $groups, $payload); + parent::__construct([]); + $this->entityClass = $entityClass; + } - $this->mode = $mode ?? $this->mode; - $this->message = $message ?? $this->message; + public function validatedBy(): string + { + return UniqueEmailValidator::class; } } diff --git a/src/Validator/Constraint/UniqueEmailValidator.php b/src/Validator/Constraint/UniqueEmailValidator.php index 2b5ab3d..906a526 100644 --- a/src/Validator/Constraint/UniqueEmailValidator.php +++ b/src/Validator/Constraint/UniqueEmailValidator.php @@ -4,7 +4,7 @@ namespace PhpList\RestBundle\Validator\Constraint; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -13,11 +13,8 @@ class UniqueEmailValidator extends ConstraintValidator { - private SubscriberRepository $subscriberRepository; - - public function __construct(SubscriberRepository $subscriberRepository) + public function __construct(private readonly EntityManagerInterface $entityManager) { - $this->subscriberRepository = $subscriberRepository; } public function validate($value, Constraint $constraint): void @@ -34,10 +31,12 @@ public function validate($value, Constraint $constraint): void throw new UnexpectedValueException($value, 'string'); } - $existingUser = $this->subscriberRepository->findOneBy(['email' => $value]); + $existingUser = $this->entityManager + ->getRepository($constraint->entityClass) + ->findOneBy(['email' => $value]); $dto = $this->context->getObject(); - $updatingId = $dto->subscriberId ?? null; + $updatingId = $dto->subscriberId ?? $dto->administratorId ?? null; if ($existingUser && $existingUser->getId() !== $updatingId) { throw new ConflictHttpException('Email already exists.'); diff --git a/src/Validator/Constraint/UniqueLoginName.php b/src/Validator/Constraint/UniqueLoginName.php new file mode 100644 index 0000000..83c52a7 --- /dev/null +++ b/src/Validator/Constraint/UniqueLoginName.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Validator/Constraint/UniqueLoginNameValidator.php b/src/Validator/Constraint/UniqueLoginNameValidator.php new file mode 100644 index 0000000..ded32c2 --- /dev/null +++ b/src/Validator/Constraint/UniqueLoginNameValidator.php @@ -0,0 +1,46 @@ +administratorRepository = $administratorRepository; + } + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof UniqueLoginName) { + throw new UnexpectedTypeException($constraint, UniqueLoginName::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + $existingUser = $this->administratorRepository->findOneBy(['loginName' => $value]); + + $dto = $this->context->getObject(); + $updatingId = $dto->administratorId ?? null; + + if ($existingUser && $existingUser->getId() !== $updatingId) { + throw new ConflictHttpException('Login already exists.'); + } + } +} diff --git a/src/Validator/RequestValidator.php b/src/Validator/RequestValidator.php index 4868f28..ae5a01d 100644 --- a/src/Validator/RequestValidator.php +++ b/src/Validator/RequestValidator.php @@ -8,14 +8,14 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use Throwable; class RequestValidator { public function __construct( - private readonly SerializerInterface $serializer, + private readonly DenormalizerInterface $serializer, private readonly ValidatorInterface $validator ) { } @@ -23,13 +23,34 @@ public function __construct( public function validate(Request $request, string $dtoClass): RequestInterface { try { - $dto = $this->serializer->deserialize( - $request->getContent(), + $body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable $e) { + throw new BadRequestHttpException('Invalid JSON: ' . $e->getMessage()); + } + $routeParams = $request->attributes->get('_route_params') ?? []; + + if (isset($routeParams['subscriberId'])) { + $routeParams['subscriberId'] = (int) $routeParams['subscriberId']; + } + if (isset($routeParams['messageId'])) { + $routeParams['messageId'] = (int) $routeParams['messageId']; + } + if (isset($routeParams['listId'])) { + $routeParams['listId'] = (int) $routeParams['listId']; + } + + $data = array_merge($routeParams, $body ?? []); + + try { + /** @var RequestInterface $dto */ + $dto = $this->serializer->denormalize( + $data, $dtoClass, - 'json' + null, + ['allow_extra_attributes' => true] ); } catch (Throwable $e) { - throw new BadRequestHttpException('Invalid JSON: ' . $e->getMessage()); + throw new BadRequestHttpException('Invalid request data: ' . $e->getMessage()); } return $this->validateDto($dto); diff --git a/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php b/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php index 985c770..4645c0e 100644 --- a/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php +++ b/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Tests\Unit\Validator\Constraint; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; use PhpList\RestBundle\Validator\Constraint\UniqueEmail; @@ -18,14 +19,14 @@ class UniqueEmailValidatorTest extends TestCase { - private SubscriberRepository|MockObject $repository; + private EntityManagerInterface|MockObject $entityManager; private UniqueEmailValidator $validator; private ExecutionContextInterface|MockObject $context; protected function setUp(): void { - $this->repository = $this->createMock(SubscriberRepository::class); - $this->validator = new UniqueEmailValidator($this->repository); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->validator = new UniqueEmailValidator($this->entityManager); $this->context = $this->createMock(ExecutionContextInterface::class); $this->validator->initialize($this->context); } @@ -38,10 +39,10 @@ public function testThrowsUnexpectedTypeExceptionWhenConstraintIsWrong(): void public function testSkipsValidationForNullOrEmpty(): void { - $this->repository->expects(self::never())->method('findOneBy'); + $this->entityManager->expects(self::never())->method('getRepository'); - $this->validator->validate(null, new UniqueEmail()); - $this->validator->validate('', new UniqueEmail()); + $this->validator->validate(null, new UniqueEmail(Subscriber::class)); + $this->validator->validate('', new UniqueEmail(Subscriber::class)); $this->addToAssertionCount(1); } @@ -49,7 +50,7 @@ public function testSkipsValidationForNullOrEmpty(): void public function testThrowsUnexpectedValueExceptionForNonString(): void { $this->expectException(UnexpectedValueException::class); - $this->validator->validate(123, new UniqueEmail()); + $this->validator->validate(123, new UniqueEmail(Subscriber::class)); } public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDifferentId(): void @@ -60,12 +61,18 @@ public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDiffere 'getId' => 99 ]); - $this->repository - ->expects(self::once()) + $repo = $this->createMock(SubscriberRepository::class); + $repo->expects(self::once()) ->method('findOneBy') ->with(['email' => $email]) ->willReturn($existingUser); + $this->entityManager + ->expects(self::once()) + ->method('getRepository') + ->with(Subscriber::class) + ->willReturn($repo); + $dto = new class { public int $subscriberId = 100; }; @@ -77,7 +84,7 @@ public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDiffere $this->expectException(ConflictHttpException::class); $this->expectExceptionMessage('Email already exists.'); - $this->validator->validate($email, new UniqueEmail()); + $this->validator->validate($email, new UniqueEmail(Subscriber::class)); } public function testAllowsSameEmailForSameSubscriberId(): void @@ -88,11 +95,18 @@ public function testAllowsSameEmailForSameSubscriberId(): void 'getId' => 100 ]); - $this->repository - ->expects(self::once()) + $repo = $this->createMock(SubscriberRepository::class); + $repo->expects(self::once()) ->method('findOneBy') + ->with(['email' => $email]) ->willReturn($existingUser); + $this->entityManager + ->expects(self::once()) + ->method('getRepository') + ->with(Subscriber::class) + ->willReturn($repo); + $dto = new class { public int $subscriberId = 100; }; @@ -101,18 +115,25 @@ public function testAllowsSameEmailForSameSubscriberId(): void ->method('getObject') ->willReturn($dto); - $this->validator->validate($email, new UniqueEmail()); + $this->validator->validate($email, new UniqueEmail(Subscriber::class)); $this->addToAssertionCount(1); } public function testAllowsUniqueEmailWhenNoExistingSubscriber(): void { - $this->repository - ->expects(self::once()) + $repo = $this->createMock(SubscriberRepository::class); + $repo->expects(self::once()) ->method('findOneBy') + ->with(['email' => 'new@example.com']) ->willReturn(null); + $this->entityManager + ->expects(self::once()) + ->method('getRepository') + ->with(Subscriber::class) + ->willReturn($repo); + $dto = new class { public int $subscriberId = 200; }; @@ -121,7 +142,7 @@ public function testAllowsUniqueEmailWhenNoExistingSubscriber(): void ->method('getObject') ->willReturn($dto); - $this->validator->validate('new@example.com', new UniqueEmail()); + $this->validator->validate('new@example.com', new UniqueEmail(Subscriber::class)); $this->addToAssertionCount(1); } diff --git a/tests/Unit/Validator/RequestValidatorTest.php b/tests/Unit/Validator/RequestValidatorTest.php index 0b4e12c..d8ed2f3 100644 --- a/tests/Unit/Validator/RequestValidatorTest.php +++ b/tests/Unit/Validator/RequestValidatorTest.php @@ -9,25 +9,24 @@ use PhpList\RestBundle\Validator\RequestValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Validator\ValidatorInterface; class RequestValidatorTest extends TestCase { - private SerializerInterface|MockObject $serializer; + private DenormalizerInterface|MockObject $serializer; private ValidatorInterface|MockObject $validator; private RequestValidator $requestValidator; protected function setUp(): void { - $this->serializer = $this->createMock(SerializerInterface::class); - $this->validator = $this->createMock(ValidatorInterface::class); + $this->serializer = $this->createMock(DenormalizerInterface::class); + $this->validator = $this->createMock(ValidatorInterface::class); $this->requestValidator = new RequestValidator( $this->serializer, $this->validator @@ -38,11 +37,17 @@ public function testValidateReturnsDtoWhenJsonValidAndNoViolations(): void { $dto = $this->createMock(RequestInterface::class); $json = '{"foo":"bar"}'; + $expectedData = ['foo' => 'bar']; $this->serializer ->expects(self::once()) - ->method('deserialize') - ->with($json, DummyRequestDto::class, 'json') + ->method('denormalize') + ->with( + $expectedData, + DummyRequestDto::class, + null, + ['allow_extra_attributes' => true] + ) ->willReturn($dto); $this->validator @@ -62,13 +67,8 @@ public function testValidateThrowsOnInvalidJson(): void $json = '{ invalid json }'; $request = new Request([], [], [], [], [], [], $json); - $this->serializer - ->expects(self::once()) - ->method('deserialize') - ->willThrowException(new RuntimeException('Syntax error')); - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('Invalid JSON: Syntax error'); + $this->expectExceptionMessage('Invalid JSON'); $this->requestValidator->validate($request, DummyRequestDto::class); } @@ -80,7 +80,8 @@ public function testValidateThrowsOnConstraintViolations(): void $request = new Request([], [], [], [], [], [], $json); $this->serializer - ->method('deserialize') + ->expects(self::once()) + ->method('denormalize') ->willReturn($dto); $violation1 = new ConstraintViolation( @@ -107,9 +108,41 @@ public function testValidateThrowsOnConstraintViolations(): void ->willReturn($violations); $this->expectException(UnprocessableEntityHttpException::class); - $this->expectExceptionMessage("email: Must not be blank\nemail: Must be a valid email"); $this->requestValidator->validate($request, DummyRequestDto::class); } + + public function testValidateMergesRouteParams(): void + { + $dto = $this->createMock(RequestInterface::class); + $json = '{"email":"foo@example.com"}'; + + $expectedData = [ + 'subscriberId' => 42, + 'email' => 'foo@example.com' + ]; + + $this->serializer + ->expects(self::once()) + ->method('denormalize') + ->with( + $expectedData, + DummyRequestDto::class, + null, + ['allow_extra_attributes' => true] + ) + ->willReturn($dto); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->with($dto) + ->willReturn(new ConstraintViolationList()); + + $request = new Request([], [], ['_route_params' => ['subscriberId' => '42']], [], [], [], $json); + + $result = $this->requestValidator->validate($request, DummyRequestDto::class); + self::assertSame($dto, $result); + } } From 9bd58bc6fb0966d6e5253cd020d572107f639cd6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 3 May 2025 21:42:47 +0400 Subject: [PATCH 58/62] ISSUE-345: pagination data provider --- config/services/providers.yml | 6 +-- src/Controller/AdministratorController.php | 12 ++--- src/Controller/CampaignController.php | 42 ++++++++++++--- src/Controller/ListController.php | 17 +++--- src/Controller/SessionController.php | 2 +- src/Controller/SubscriberController.php | 2 +- src/Controller/SubscriptionController.php | 2 +- src/Controller/TemplateController.php | 2 +- .../Provider/AdministratorProvider.php | 36 ------------- .../Provider/PaginatedDataProvider.php | 52 +++++++++++++++++++ .../Provider/SubscriberListProvider.php | 33 ------------ .../Controller/TemplateControllerTest.php | 2 +- 12 files changed, 104 insertions(+), 104 deletions(-) delete mode 100644 src/Service/Provider/AdministratorProvider.php create mode 100644 src/Service/Provider/PaginatedDataProvider.php delete mode 100644 src/Service/Provider/SubscriberListProvider.php diff --git a/config/services/providers.yml b/config/services/providers.yml index 47b380f..49c7ff7 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -4,10 +4,6 @@ services: autoconfigure: true public: false - PhpList\RestBundle\Service\Provider\SubscriberListProvider: - autowire: true - autoconfigure: true - - PhpList\RestBundle\Service\Provider\AdministratorProvider: + PhpList\RestBundle\Service\Provider\PaginatedDataProvider: autowire: true autoconfigure: true diff --git a/src/Controller/AdministratorController.php b/src/Controller/AdministratorController.php index 127032f..e7a45e0 100644 --- a/src/Controller/AdministratorController.php +++ b/src/Controller/AdministratorController.php @@ -11,7 +11,7 @@ use PhpList\RestBundle\Entity\Request\UpdateAdministratorRequest; use PhpList\RestBundle\Serializer\AdministratorNormalizer; use PhpList\RestBundle\Service\Manager\AdministratorManager; -use PhpList\RestBundle\Service\Provider\AdministratorProvider; +use PhpList\RestBundle\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; @@ -28,19 +28,19 @@ class AdministratorController extends BaseController { private AdministratorManager $administratorManager; private AdministratorNormalizer $normalizer; - private AdministratorProvider $administratorProvider; + private PaginatedDataProvider $paginatedProvider; public function __construct( Authentication $authentication, + RequestValidator $validator, AdministratorManager $administratorManager, AdministratorNormalizer $normalizer, - RequestValidator $validator, - AdministratorProvider $administratorProvider + PaginatedDataProvider $paginatedProvider ) { parent::__construct($authentication, $validator); $this->administratorManager = $administratorManager; $this->normalizer = $normalizer; - $this->administratorProvider = $administratorProvider; + $this->paginatedProvider = $paginatedProvider; } #[Route('', name: 'get_administrators', methods: ['GET'])] @@ -101,7 +101,7 @@ public function getAdministrators(Request $request): JsonResponse $this->requireAuthentication($request); return new JsonResponse( - $this->administratorProvider->getPaginatedList($request), + $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Administrator::class), Response::HTTP_OK ); } diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 0e78776..79ee014 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -5,12 +5,14 @@ namespace PhpList\RestBundle\Controller; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Filter\MessageFilter; use PhpList\Core\Domain\Model\Messaging\Message; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Entity\Request\CreateMessageRequest; use PhpList\RestBundle\Entity\Request\UpdateMessageRequest; use PhpList\RestBundle\Serializer\MessageNormalizer; use PhpList\RestBundle\Service\Manager\MessageManager; +use PhpList\RestBundle\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; @@ -29,16 +31,19 @@ class CampaignController extends BaseController { private MessageNormalizer $normalizer; private MessageManager $messageManager; + private PaginatedDataProvider $paginatedProvider; public function __construct( Authentication $authentication, RequestValidator $validator, MessageNormalizer $normalizer, - MessageManager $messageManager + MessageManager $messageManager, + PaginatedDataProvider $paginatedProvider, ) { parent::__construct($authentication, $validator); $this->normalizer = $normalizer; $this->messageManager = $messageManager; + $this->paginatedProvider = $paginatedProvider; } #[Route('', name: 'get_campaigns', methods: ['GET'])] @@ -56,6 +61,20 @@ public function __construct( schema: new OA\Schema( type: 'string' ) + ), + new OA\Parameter( + name: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) ) ], responses: [ @@ -63,8 +82,15 @@ public function __construct( response: 200, description: 'Success', content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Message') + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Message') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' ) ), new OA\Response( @@ -77,13 +103,13 @@ public function __construct( public function getMessages(Request $request): JsonResponse { $authUer = $this->requireAuthentication($request); - $data = $this->messageManager->getMessagesByOwner($authUer); - $normalized = array_map(function ($item) { - return $this->normalizer->normalize($item); - }, $data); + $filter = (new MessageFilter())->setOwner($authUer); - return new JsonResponse($normalized, Response::HTTP_OK); + return new JsonResponse( + $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter), + Response::HTTP_OK + ); } #[Route('/{messageId}', name: 'get_campaign', methods: ['GET'])] diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 18dcd3a..39f6a52 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -9,9 +9,8 @@ use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Entity\Request\CreateSubscriberListRequest; use PhpList\RestBundle\Serializer\SubscriberListNormalizer; -use PhpList\RestBundle\Service\Factory\PaginationCursorRequestFactory; use PhpList\RestBundle\Service\Manager\SubscriberListManager; -use PhpList\RestBundle\Service\Provider\SubscriberListProvider; +use PhpList\RestBundle\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; @@ -32,22 +31,19 @@ class ListController extends BaseController { private SubscriberListNormalizer $normalizer; private SubscriberListManager $subscriberListManager; - private PaginationCursorRequestFactory $paginationFactory; - private SubscriberListProvider $subscriberListProvider; + private PaginatedDataProvider $paginatedDataProvider; public function __construct( Authentication $authentication, - SubscriberListNormalizer $normalizer, RequestValidator $validator, + SubscriberListNormalizer $normalizer, SubscriberListManager $subscriberListManager, - PaginationCursorRequestFactory $paginationFactory, - SubscriberListProvider $subscriberListProvider + PaginatedDataProvider $paginatedDataProvider, ) { parent::__construct($authentication, $validator); $this->normalizer = $normalizer; $this->subscriberListManager = $subscriberListManager; - $this->paginationFactory = $paginationFactory; - $this->subscriberListProvider = $subscriberListProvider; + $this->paginatedDataProvider = $paginatedDataProvider; } #[Route('', name: 'get_lists', methods: ['GET'])] @@ -107,10 +103,9 @@ public function __construct( public function getLists(Request $request): JsonResponse { $this->requireAuthentication($request); - $pagination = $this->paginationFactory->fromRequest($request); return new JsonResponse( - $this->subscriberListProvider->getPaginatedList($pagination), + $this->paginatedDataProvider->getPaginatedList($request, $this->normalizer, SubscriberList::class), Response::HTTP_OK ); } diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index e248a9c..6bc38a3 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -32,8 +32,8 @@ class SessionController extends BaseController public function __construct( Authentication $authentication, + RequestValidator $validator, SessionManager $sessionManager, - RequestValidator $validator ) { parent::__construct($authentication, $validator); diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 65c4eee..532e50e 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -33,8 +33,8 @@ class SubscriberController extends BaseController public function __construct( Authentication $authentication, - SubscriberManager $subscriberManager, RequestValidator $validator, + SubscriberManager $subscriberManager, SubscriberNormalizer $subscriberNormalizer, ) { parent::__construct($authentication, $validator); diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index a2a7aa7..59638da 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -33,8 +33,8 @@ class SubscriptionController extends BaseController public function __construct( Authentication $authentication, - SubscriptionManager $subscriptionManager, RequestValidator $validator, + SubscriptionManager $subscriptionManager, SubscriberNormalizer $subscriberNormalizer, SubscriptionNormalizer $subscriptionNormalizer, ) { diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index b294f6f..df4547b 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -33,9 +33,9 @@ class TemplateController extends BaseController public function __construct( Authentication $authentication, + RequestValidator $validator, TemplateRepository $templateRepository, TemplateNormalizer $normalizer, - RequestValidator $validator, TemplateManager $templateManager ) { parent::__construct($authentication, $validator); diff --git a/src/Service/Provider/AdministratorProvider.php b/src/Service/Provider/AdministratorProvider.php deleted file mode 100644 index 6afc330..0000000 --- a/src/Service/Provider/AdministratorProvider.php +++ /dev/null @@ -1,36 +0,0 @@ -paginationFactory->fromRequest($request); - $lists = $this->administratorRepository->getAfterId($pagination->afterId, $pagination->limit); - $total = $this->administratorRepository->count(); - - $normalized = array_map(fn($item) => $this->normalizer->normalize($item), $lists); - - return $this->paginationNormalizer->normalize( - new CursorPaginationResult($normalized, $pagination->limit, $total) - ); - } -} diff --git a/src/Service/Provider/PaginatedDataProvider.php b/src/Service/Provider/PaginatedDataProvider.php new file mode 100644 index 0000000..b26657a --- /dev/null +++ b/src/Service/Provider/PaginatedDataProvider.php @@ -0,0 +1,52 @@ +paginationFactory->fromRequest($request); + + $repository = $this->entityManager->getRepository($className); + + if (!$repository instanceof PaginatableRepositoryInterface) { + throw new RuntimeException('Repository not found'); + } + + $items = $repository->getFilteredAfterId($pagination->afterId, $pagination->limit, $filter); + $total = $repository->count(); + + $normalizedItems = array_map( + fn($item) => $normalizer->normalize($item, 'json'), + $items + ); + + return $this->paginationNormalizer->normalize( + new CursorPaginationResult($normalizedItems, $pagination->limit, $total) + ); + } +} diff --git a/src/Service/Provider/SubscriberListProvider.php b/src/Service/Provider/SubscriberListProvider.php deleted file mode 100644 index ef21d83..0000000 --- a/src/Service/Provider/SubscriberListProvider.php +++ /dev/null @@ -1,33 +0,0 @@ -subscriberListManager->getPaginated($pagination); - $total = $this->subscriberListManager->getTotalCount(); - - $normalized = array_map(fn($item) => $this->normalizer->normalize($item), $lists); - - return $this->paginationNormalizer->normalize( - new CursorPaginationResult($normalized, $pagination->limit, $total) - ); - } -} diff --git a/tests/Unit/Controller/TemplateControllerTest.php b/tests/Unit/Controller/TemplateControllerTest.php index a116575..b710773 100644 --- a/tests/Unit/Controller/TemplateControllerTest.php +++ b/tests/Unit/Controller/TemplateControllerTest.php @@ -36,9 +36,9 @@ protected function setUp(): void $this->controller = new TemplateController( $authentication, + $this->validator, $this->templateRepository, $this->normalizer, - $this->validator, $this->templateManager ); } From 027a465188426b854f26ab91fc060ea863beac9a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 4 May 2025 19:03:34 +0400 Subject: [PATCH 59/62] ISSUE-345: subscriptions --- src/Controller/SubscriptionController.php | 53 +++++++-- .../Controller/CampaignControllerTest.php | 4 +- .../Controller/ListControllerTest.php | 108 ++++++++++-------- 3 files changed, 106 insertions(+), 59 deletions(-) diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index 59638da..1caa85e 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -5,18 +5,20 @@ namespace PhpList\RestBundle\Controller; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Filter\SubscriberFilter; +use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Entity\Request\SubscriptionRequest; use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Serializer\SubscriptionNormalizer; use PhpList\RestBundle\Service\Manager\SubscriptionManager; +use PhpList\RestBundle\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -30,6 +32,7 @@ class SubscriptionController extends BaseController private SubscriptionManager $subscriptionManager; private SubscriberNormalizer $subscriberNormalizer; private SubscriptionNormalizer $subscriptionNormalizer; + private PaginatedDataProvider $paginatedProvider; public function __construct( Authentication $authentication, @@ -37,11 +40,13 @@ public function __construct( SubscriptionManager $subscriptionManager, SubscriberNormalizer $subscriberNormalizer, SubscriptionNormalizer $subscriptionNormalizer, + PaginatedDataProvider $paginatedProvider, ) { parent::__construct($authentication, $validator); $this->subscriptionManager = $subscriptionManager; $this->subscriberNormalizer = $subscriberNormalizer; $this->subscriptionNormalizer = $subscriptionNormalizer; + $this->paginatedProvider = $paginatedProvider; } #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] @@ -64,6 +69,20 @@ public function __construct( in: 'path', required: true, schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) ) ], responses: [ @@ -71,8 +90,15 @@ public function __construct( response: 200, description: 'Success', content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Subscriber') + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Subscriber') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' ) ), new OA\Response( @@ -94,13 +120,18 @@ public function getListMembers( $this->requireAuthentication($request); if (!$list) { - throw new NotFoundHttpException('Subscriber list not found.'); + throw $this->createNotFoundException('Subscriber list not found.'); } - $subscribers = $this->subscriptionManager->getSubscriberListMembers($list); - $normalized = array_map(fn($subscriber) => $this->subscriberNormalizer->normalize($subscriber), $subscribers); - - return new JsonResponse($normalized, Response::HTTP_OK); + return new JsonResponse( + $this->paginatedProvider->getPaginatedList( + $request, + $this->subscriberNormalizer, + Subscriber::class, + (new SubscriberFilter())->setListId($list->getId()) + ), + Response::HTTP_OK + ); } #[Route('/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] @@ -154,7 +185,7 @@ public function getSubscribersCount( $this->requireAuthentication($request); if (!$list) { - throw new NotFoundHttpException('Subscriber list not found.'); + throw $this->createNotFoundException('Subscriber list not found.'); } return new JsonResponse(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); @@ -240,7 +271,7 @@ public function createSubscription( $this->requireAuthentication($request); if (!$list) { - throw new NotFoundHttpException('Subscriber list not found.'); + throw $this->createNotFoundException('Subscriber list not found.'); } /** @var SubscriptionRequest $subscriptionRequest */ @@ -303,7 +334,7 @@ public function deleteSubscriptions( ): JsonResponse { $this->requireAuthentication($request); if (!$list) { - throw new NotFoundHttpException('Subscriber list not found.'); + throw $this->createNotFoundException('Subscriber list not found.'); } $subscriptionRequest = new SubscriptionRequest(); $subscriptionRequest->emails = $request->query->all('emails'); diff --git a/tests/Integration/Controller/CampaignControllerTest.php b/tests/Integration/Controller/CampaignControllerTest.php index 6d56dc3..9949b7c 100644 --- a/tests/Integration/Controller/CampaignControllerTest.php +++ b/tests/Integration/Controller/CampaignControllerTest.php @@ -51,8 +51,8 @@ public function testGetCampaignsReturnsCampaignData(): void $response = $this->getDecodedJsonResponseContent(); self::assertIsArray($response); - self::assertArrayHasKey('id', $response[0]); - self::assertArrayHasKey('message_content', $response[0]); + self::assertArrayHasKey('id', $response['items'][0]); + self::assertArrayHasKey('message_content', $response['items'][0]); } public function testGetSingleCampaignWithValidSessionReturnsData(): void diff --git a/tests/Integration/Controller/ListControllerTest.php b/tests/Integration/Controller/ListControllerTest.php index dd0ba65..97dea15 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -232,7 +232,15 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithoutSub $this->authenticatedJsonRequest('get', '/api/v2/lists/1/subscribers'); - $this->assertJsonResponseContentEquals([]); + $this->assertJsonResponseContentEquals([ + 'items' => [], + 'pagination' => [ + 'total' => 0, + 'limit' => 25, + 'has_more' => false, + 'next_cursor' => null, + ] + ]); } public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscribersReturnsSubscribers() @@ -243,55 +251,63 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr $this->assertJsonResponseContentEquals( [ - [ - 'id' => 1, - 'email' => 'oliver@example.com', - 'created_at' => '2016-07-22T15:01:17+00:00', - 'confirmed' => true, - 'blacklisted' => true, - 'bounce_count' => 17, - 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e89', - 'html_email' => true, - 'disabled' => true, - 'subscribed_lists' => [ - [ - 'id' => 2, - 'name' => 'More news', - 'description' => '', - 'created_at' => '2016-06-22T15:01:17+00:00', - 'public' => true, - 'subscription_date' => '2016-07-22T15:01:17+00:00', - ], - ], - ], [ - 'id' => 2, - 'email' => 'oliver1@example.com', - 'created_at' => '2016-07-22T15:01:17+00:00', - 'confirmed' => true, - 'blacklisted' => true, - 'bounce_count' => 17, - 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e87', - 'html_email' => true, - 'disabled' => true, - 'subscribed_lists' => [ - [ - 'id' => 2, - 'name' => 'More news', - 'description' => '', - 'created_at' => '2016-06-22T15:01:17+00:00', - 'public' => true, - 'subscription_date' => '2016-08-22T15:01:17+00:00', + 'items' => [ + [ + 'id' => 1, + 'email' => 'oliver@example.com', + 'created_at' => '2016-07-22T15:01:17+00:00', + 'confirmed' => true, + 'blacklisted' => true, + 'bounce_count' => 17, + 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e89', + 'html_email' => true, + 'disabled' => true, + 'subscribed_lists' => [ + [ + 'id' => 2, + 'name' => 'More news', + 'description' => '', + 'created_at' => '2016-06-22T15:01:17+00:00', + 'public' => true, + 'subscription_date' => '2016-07-22T15:01:17+00:00', + ], ], - [ - 'id' => 1, - 'name' => 'News', - 'description' => 'News (and some fun stuff)', - 'created_at' => '2016-06-22T15:01:17+00:00', - 'public' => true, - 'subscription_date' => '2016-09-22T15:01:17+00:00', + ], [ + 'id' => 2, + 'email' => 'oliver1@example.com', + 'created_at' => '2016-07-22T15:01:17+00:00', + 'confirmed' => true, + 'blacklisted' => true, + 'bounce_count' => 17, + 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e87', + 'html_email' => true, + 'disabled' => true, + 'subscribed_lists' => [ + [ + 'id' => 2, + 'name' => 'More news', + 'description' => '', + 'created_at' => '2016-06-22T15:01:17+00:00', + 'public' => true, + 'subscription_date' => '2016-08-22T15:01:17+00:00', + ], + [ + 'id' => 1, + 'name' => 'News', + 'description' => 'News (and some fun stuff)', + 'created_at' => '2016-06-22T15:01:17+00:00', + 'public' => true, + 'subscription_date' => '2016-09-22T15:01:17+00:00', + ], ], ], ], + 'pagination' => [ + 'total' => 3, + 'limit' => 25, + 'has_more' => false, + 'next_cursor' => 2, + ], ] ); } From 7ee40e8aceaa9f94885aa5550ef07ba0f91cf97d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 4 May 2025 19:23:06 +0400 Subject: [PATCH 60/62] ISSUE-345: paginated templates --- src/Controller/AdministratorController.php | 17 ++- src/Controller/CampaignController.php | 17 ++- src/Controller/ListController.php | 13 +- src/Controller/SessionController.php | 8 +- src/Controller/SubscriberController.php | 13 +- src/Controller/SubscriptionController.php | 8 +- src/Controller/TemplateController.php | 58 ++++++--- .../Controller/TemplateControllerTest.php | 4 +- .../Controller/TemplateControllerTest.php | 118 ------------------ 9 files changed, 77 insertions(+), 179 deletions(-) delete mode 100644 tests/Unit/Controller/TemplateControllerTest.php diff --git a/src/Controller/AdministratorController.php b/src/Controller/AdministratorController.php index e7a45e0..f2e8bab 100644 --- a/src/Controller/AdministratorController.php +++ b/src/Controller/AdministratorController.php @@ -17,7 +17,6 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -100,7 +99,7 @@ public function getAdministrators(Request $request): JsonResponse { $this->requireAuthentication($request); - return new JsonResponse( + return $this->json( $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Administrator::class), Response::HTTP_OK ); @@ -141,7 +140,7 @@ public function createAdministrator( $administrator = $this->administratorManager->createAdministrator($dto); $json = $normalizer->normalize($administrator, 'json'); - return new JsonResponse($json, Response::HTTP_CREATED); + return $this->json($json, Response::HTTP_CREATED); } #[Route('/{administratorId}', name: 'get_administrator', methods: ['GET'])] @@ -178,11 +177,11 @@ public function getAdministrator( $this->requireAuthentication($request); if (!$administrator) { - throw new NotFoundHttpException('Administrator not found.'); + throw $this->createNotFoundException('Administrator not found.'); } $json = $this->normalizer->normalize($administrator, 'json'); - return new JsonResponse($json, Response::HTTP_OK); + return $this->json($json, Response::HTTP_OK); } #[Route('/{administratorId}', name: 'update_administrator', methods: ['PUT'])] @@ -223,13 +222,13 @@ public function updateAdministrator( $this->requireAuthentication($request); if (!$administrator) { - throw new NotFoundHttpException('Administrator not found.'); + throw $this->createNotFoundException('Administrator not found.'); } /** @var UpdateAdministratorRequest $dto */ $dto = $this->validator->validate($request, UpdateAdministratorRequest::class); $this->administratorManager->updateAdministrator($administrator, $dto); - return new JsonResponse(null, Response::HTTP_OK); + return $this->json(null, Response::HTTP_OK); } #[Route('/{administratorId}', name: 'delete_administrator', methods: ['DELETE'])] @@ -265,10 +264,10 @@ public function deleteAdministrator( $this->requireAuthentication($request); if (!$administrator) { - throw new NotFoundHttpException('Administrator not found.'); + throw $this->createNotFoundException('Administrator not found.'); } $this->administratorManager->deleteAdministrator($administrator); - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/CampaignController.php b/src/Controller/CampaignController.php index 79ee014..73789bb 100644 --- a/src/Controller/CampaignController.php +++ b/src/Controller/CampaignController.php @@ -18,7 +18,6 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -106,7 +105,7 @@ public function getMessages(Request $request): JsonResponse $filter = (new MessageFilter())->setOwner($authUer); - return new JsonResponse( + return $this->json( $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter), Response::HTTP_OK ); @@ -156,10 +155,10 @@ public function getMessage( $this->requireAuthentication($request); if (!$message) { - throw new NotFoundHttpException('Campaign not found.'); + throw $this->createNotFoundException('Campaign not found.'); } - return new JsonResponse($this->normalizer->normalize($message), Response::HTTP_OK); + return $this->json($this->normalizer->normalize($message), Response::HTTP_OK); } #[Route('', name: 'create_message', methods: ['POST'])] @@ -221,7 +220,7 @@ public function createMessage(Request $request, MessageNormalizer $normalizer): $createMessageRequest = $this->validator->validate($request, CreateMessageRequest::class); $data = $this->messageManager->createMessage($createMessageRequest, $authUser); - return new JsonResponse($normalizer->normalize($data), Response::HTTP_CREATED); + return $this->json($normalizer->normalize($data), Response::HTTP_CREATED); } #[Route('/{messageId}', name: 'update_campaign', methods: ['PUT'])] @@ -288,13 +287,13 @@ public function updateMessage( $authUser = $this->requireAuthentication($request); if (!$message) { - throw new NotFoundHttpException('Campaign not found.'); + throw $this->createNotFoundException('Campaign not found.'); } /** @var UpdateMessageRequest $updateMessageRequest */ $updateMessageRequest = $this->validator->validate($request, UpdateMessageRequest::class); $data = $this->messageManager->updateMessage($updateMessageRequest, $message, $authUser); - return new JsonResponse($this->normalizer->normalize($data), Response::HTTP_OK); + return $this->json($this->normalizer->normalize($data), Response::HTTP_OK); } #[Route('/{messageId}', name: 'delete_campaign', methods: ['DELETE'])] @@ -346,11 +345,11 @@ public function deleteMessage( $this->requireAuthentication($request); if (!$message) { - throw new NotFoundHttpException('Campaign not found.'); + throw $this->createNotFoundException('Campaign not found.'); } $this->messageManager->delete($message); - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index 39f6a52..c7af395 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -16,7 +16,6 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -104,7 +103,7 @@ public function getLists(Request $request): JsonResponse { $this->requireAuthentication($request); - return new JsonResponse( + return $this->json( $this->paginatedDataProvider->getPaginatedList($request, $this->normalizer, SubscriberList::class), Response::HTTP_OK ); @@ -166,10 +165,10 @@ public function getList( $this->requireAuthentication($request); if (!$list) { - throw new NotFoundHttpException('Subscriber list not found.'); + throw $this->createNotFoundException('Subscriber list not found.'); } - return new JsonResponse($this->normalizer->normalize($list), Response::HTTP_OK); + return $this->json($this->normalizer->normalize($list), Response::HTTP_OK); } #[Route('/{listId}', name: 'delete_list', methods: ['DELETE'])] @@ -218,12 +217,12 @@ public function deleteList( $this->requireAuthentication($request); if (!$list) { - throw new NotFoundHttpException('Subscriber list not found.'); + throw $this->createNotFoundException('Subscriber list not found.'); } $this->subscriberListManager->delete($list); - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->json(null, Response::HTTP_NO_CONTENT); } #[Route('', name: 'create_list', methods: ['POST'])] @@ -282,6 +281,6 @@ public function createList(Request $request, SubscriberListNormalizer $normalize $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest, $authUser); - return new JsonResponse($normalizer->normalize($data), Response::HTTP_CREATED); + return $this->json($normalizer->normalize($data), Response::HTTP_CREATED); } } diff --git a/src/Controller/SessionController.php b/src/Controller/SessionController.php index 6bc38a3..9755d69 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -16,7 +16,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -95,7 +94,7 @@ public function createSession( $json = $normalizer->normalize($token, 'json'); - return new JsonResponse($json, Response::HTTP_CREATED); + return $this->json($json, Response::HTTP_CREATED); } /** @@ -104,7 +103,6 @@ public function createSession( * This action may only be called for sessions that are owned by the authenticated administrator. * * @throws AccessDeniedHttpException - * @throws NotFoundHttpException */ #[Route('/{sessionId}', name: 'delete_session', methods: ['DELETE'])] #[OA\Delete( @@ -145,7 +143,7 @@ public function deleteSession( $administrator = $this->requireAuthentication($request); if (!$token) { - throw new NotFoundHttpException('Token not found.'); + throw $this->createNotFoundException('Token not found.'); } if ($token->getAdministrator() !== $administrator) { throw new AccessDeniedHttpException('You do not have access to this session.', null, 1519831644); @@ -153,6 +151,6 @@ public function deleteSession( $this->sessionManager->deleteSession($token); - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 532e50e..7ed809d 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -16,7 +16,6 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -101,7 +100,7 @@ public function createSubscriber(Request $request): JsonResponse $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest); - return new JsonResponse( + return $this->json( $this->subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_CREATED ); @@ -174,13 +173,13 @@ public function updateSubscriber( $this->requireAuthentication($request); if (!$subscriber) { - throw new NotFoundHttpException('Subscriber not found.'); + throw $this->createNotFoundException('Subscriber not found.'); } /** @var UpdateSubscriberRequest $dto */ $dto = $this->validator->validate($request, UpdateSubscriberRequest::class); $subscriber = $this->subscriberManager->updateSubscriber($dto); - return new JsonResponse($this->subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_OK); + return $this->json($this->subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_OK); } #[Route('/{subscriberId}', name: 'get_subscriber_by_id', methods: ['GET'])] @@ -229,7 +228,7 @@ public function getSubscriber(Request $request, int $subscriberId): JsonResponse $subscriber = $this->subscriberManager->getSubscriber($subscriberId); - return new JsonResponse($this->subscriberNormalizer->normalize($subscriber), Response::HTTP_OK); + return $this->json($this->subscriberNormalizer->normalize($subscriber), Response::HTTP_OK); } #[Route('/{subscriberId}', name: 'delete_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])] @@ -278,10 +277,10 @@ public function deleteSubscriber( $this->requireAuthentication($request); if (!$subscriber) { - throw new NotFoundHttpException('Subscriber not found.'); + throw $this->createNotFoundException('Subscriber not found.'); } $this->subscriberManager->deleteSubscriber($subscriber); - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index 1caa85e..90f8309 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -123,7 +123,7 @@ public function getListMembers( throw $this->createNotFoundException('Subscriber list not found.'); } - return new JsonResponse( + return $this->json( $this->paginatedProvider->getPaginatedList( $request, $this->subscriberNormalizer, @@ -188,7 +188,7 @@ public function getSubscribersCount( throw $this->createNotFoundException('Subscriber list not found.'); } - return new JsonResponse(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); + return $this->json(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); } #[Route('/{listId}/subscribers', name: 'create_subscription', methods: ['POST'])] @@ -279,7 +279,7 @@ public function createSubscription( $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); - return new JsonResponse($normalized, Response::HTTP_CREATED); + return $this->json($normalized, Response::HTTP_CREATED); } #[Route('/{listId}/subscribers', name: 'delete_subscription', methods: ['DELETE'])] @@ -343,6 +343,6 @@ public function deleteSubscriptions( $subscriptionRequest = $this->validator->validateDto($subscriptionRequest); $this->subscriptionManager->deleteSubscriptions($list, $subscriptionRequest->emails); - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php index df4547b..7914dd3 100644 --- a/src/Controller/TemplateController.php +++ b/src/Controller/TemplateController.php @@ -6,17 +6,16 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Model\Messaging\Template; -use PhpList\Core\Domain\Repository\Messaging\TemplateRepository; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Entity\Request\CreateTemplateRequest; use PhpList\RestBundle\Serializer\TemplateNormalizer; use PhpList\RestBundle\Service\Manager\TemplateManager; +use PhpList\RestBundle\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -27,21 +26,21 @@ #[Route('/templates')] class TemplateController extends BaseController { - private TemplateRepository $templateRepository; private TemplateNormalizer $normalizer; private TemplateManager $templateManager; + private PaginatedDataProvider $paginatedDataProvider; public function __construct( Authentication $authentication, RequestValidator $validator, - TemplateRepository $templateRepository, TemplateNormalizer $normalizer, - TemplateManager $templateManager + TemplateManager $templateManager, + PaginatedDataProvider $paginatedDataProvider, ) { parent::__construct($authentication, $validator); - $this->templateRepository = $templateRepository; $this->normalizer = $normalizer; $this->templateManager = $templateManager; + $this->paginatedDataProvider = $paginatedDataProvider; } #[Route('', name: 'get_templates', methods: ['GET'])] @@ -59,6 +58,20 @@ public function __construct( schema: new OA\Schema( type: 'string' ) + ), + new OA\Parameter( + name: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) ) ], responses: [ @@ -66,8 +79,15 @@ public function __construct( response: 200, description: 'Success', content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Template') + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Template') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' ) ), new OA\Response( @@ -80,13 +100,15 @@ public function __construct( public function getTemplates(Request $request): JsonResponse { $this->requireAuthentication($request); - $data = $this->templateRepository->findAll(); - - $normalized = array_map(function ($item) { - return $this->normalizer->normalize($item); - }, $data); - return new JsonResponse($normalized, Response::HTTP_OK); + return $this->json( + $this->paginatedDataProvider->getPaginatedList( + $request, + $this->normalizer, + Template::class, + ), + Response::HTTP_OK + ); } #[Route('/{templateId}', name: 'get_template', methods: ['GET'])] @@ -141,7 +163,7 @@ public function getTemplate( throw $this->createNotFoundException('Template not found.'); } - return new JsonResponse($this->normalizer->normalize($template), Response::HTTP_OK); + return $this->json($this->normalizer->normalize($template), Response::HTTP_OK); } #[Route('', name: 'create_template', methods: ['POST'])] @@ -241,7 +263,7 @@ public function createTemplates(Request $request): JsonResponse /** @var CreateTemplateRequest $createTemplateRequest */ $createTemplateRequest = $this->validator->validate($request, CreateTemplateRequest::class); - return new JsonResponse( + return $this->json( $this->normalizer->normalize($this->templateManager->create($createTemplateRequest)), Response::HTTP_CREATED ); @@ -293,11 +315,11 @@ public function delete( $this->requireAuthentication($request); if (!$template) { - throw new NotFoundHttpException('Template not found.'); + throw $this->createNotFoundException('Template not found.'); } $this->templateManager->delete($template); - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/tests/Integration/Controller/TemplateControllerTest.php b/tests/Integration/Controller/TemplateControllerTest.php index 793b9c3..af65097 100644 --- a/tests/Integration/Controller/TemplateControllerTest.php +++ b/tests/Integration/Controller/TemplateControllerTest.php @@ -52,8 +52,8 @@ public function testGetTemplatesReturnsTemplateData(): void $response = $this->getDecodedJsonResponseContent(); self::assertIsArray($response); - self::assertArrayHasKey('id', $response[0]); - self::assertArrayHasKey('title', $response[0]); + self::assertArrayHasKey('id', $response['items'][0]); + self::assertArrayHasKey('title', $response['items'][0]); } public function testGetTemplateWithoutSessionKeyReturnsForbidden(): void diff --git a/tests/Unit/Controller/TemplateControllerTest.php b/tests/Unit/Controller/TemplateControllerTest.php deleted file mode 100644 index b710773..0000000 --- a/tests/Unit/Controller/TemplateControllerTest.php +++ /dev/null @@ -1,118 +0,0 @@ -createMock(Authentication::class); - $authentication->method('authenticateByApiKey')->willReturn(new Administrator()); - $this->templateRepository = $this->createMock(TemplateRepository::class); - $this->normalizer = $this->createMock(TemplateNormalizer::class); - $this->validator = $this->createMock(RequestValidator::class); - $this->templateManager = $this->createMock(TemplateManager::class); - - $this->controller = new TemplateController( - $authentication, - $this->validator, - $this->templateRepository, - $this->normalizer, - $this->templateManager - ); - } - - public function testGetTemplatesReturnsTemplates(): void - { - $request = $this->createMock(Request::class); - - $template = $this->createMock(Template::class); - - $this->templateRepository->expects($this->once()) - ->method('findAll') - ->willReturn([$template]); - - $this->normalizer->expects($this->once()) - ->method('normalize') - ->with($template) - ->willReturn(['id' => 1, 'title' => 'Test Template']); - - $response = $this->controller->getTemplates($request); - - $this->assertSame(200, $response->getStatusCode()); - - $data = json_decode($response->getContent(), true); - $this->assertEquals([['id' => 1, 'title' => 'Test Template']], $data); - } - - public function testGetTemplateReturnsSingleTemplate(): void - { - $request = $this->createMock(Request::class); - - $template = $this->createMock(Template::class); - - $this->normalizer->expects($this->once()) - ->method('normalize') - ->with($template) - ->willReturn(['id' => 1, 'title' => 'Single Template']); - - $response = $this->controller->getTemplate($request, $template); - - $this->assertSame(200, $response->getStatusCode()); - - $data = json_decode($response->getContent(), true); - $this->assertEquals(['id' => 1, 'title' => 'Single Template'], $data); - } - - public function testCreateTemplateReturnsCreatedTemplate(): void - { - $request = $this->createMock(Request::class); - - $createTemplateRequest = $this->createMock(CreateTemplateRequest::class); - - $this->validator->expects($this->once()) - ->method('validate') - ->with($request, CreateTemplateRequest::class) - ->willReturn($createTemplateRequest); - - $template = $this->createMock(Template::class); - - $this->templateManager->expects($this->once()) - ->method('create') - ->with($createTemplateRequest) - ->willReturn($template); - - $this->normalizer->expects($this->once()) - ->method('normalize') - ->with($template) - ->willReturn(['id' => 1, 'title' => 'Created Template']); - - $response = $this->controller->createTemplates($request); - - $this->assertSame(201, $response->getStatusCode()); - - $data = json_decode($response->getContent(), true); - $this->assertEquals(['id' => 1, 'title' => 'Created Template'], $data); - } -} From fcc12db10bc123a569570922351abc45b3783048 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 4 May 2025 19:49:55 +0400 Subject: [PATCH 61/62] ISSUE-345: split controller --- src/Controller/AdministratorController.php | 2 +- src/Controller/ListMembersController.php | 179 ++++++++++++++++++ src/Controller/SubscriptionController.php | 152 --------------- .../Controller/ListMembersControllerTest.php | 93 +++++++++ .../Controller/SubscriptionControllerTest.php | 70 ------- 5 files changed, 273 insertions(+), 223 deletions(-) create mode 100644 src/Controller/ListMembersController.php create mode 100644 tests/Integration/Controller/ListMembersControllerTest.php diff --git a/src/Controller/AdministratorController.php b/src/Controller/AdministratorController.php index f2e8bab..1adb2be 100644 --- a/src/Controller/AdministratorController.php +++ b/src/Controller/AdministratorController.php @@ -43,7 +43,7 @@ public function __construct( } #[Route('', name: 'get_administrators', methods: ['GET'])] - #[OA\Post( + #[OA\Get( path: '/administrators', description: 'Get list of administrators.', summary: 'Get Administrators', diff --git a/src/Controller/ListMembersController.php b/src/Controller/ListMembersController.php new file mode 100644 index 0000000..f499a82 --- /dev/null +++ b/src/Controller/ListMembersController.php @@ -0,0 +1,179 @@ +subscriberNormalizer = $subscriberNormalizer; + $this->paginatedProvider = $paginatedProvider; + } + + #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] + #[OA\Get( + path: '/lists/{listId}/subscribers', + description: 'Returns a JSON list of all subscribers for a subscriber list.', + summary: 'Gets a list of all subscribers of a subscriber list.', + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Subscriber') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' + ) + ), + 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 getListMembers( + Request $request, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + + return $this->json( + $this->paginatedProvider->getPaginatedList( + $request, + $this->subscriberNormalizer, + Subscriber::class, + (new SubscriberFilter())->setListId($list->getId()) + ), + Response::HTTP_OK + ); + } + + #[Route('/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] + #[OA\Get( + path: '/lists/{listId}/count', + description: 'Returns a count of all subscribers in a given list.', + summary: 'Gets the total number of subscribers of a list', + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'subscribers_count', + type: 'integer', + example: 42 + ) + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getSubscribersCount( + Request $request, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + + return $this->json(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); + } +} diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php index 90f8309..ea2dacc 100644 --- a/src/Controller/SubscriptionController.php +++ b/src/Controller/SubscriptionController.php @@ -5,15 +5,11 @@ namespace PhpList\RestBundle\Controller; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Filter\SubscriberFilter; -use PhpList\Core\Domain\Model\Subscription\Subscriber; use PhpList\Core\Domain\Model\Subscription\SubscriberList; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Entity\Request\SubscriptionRequest; -use PhpList\RestBundle\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Serializer\SubscriptionNormalizer; use PhpList\RestBundle\Service\Manager\SubscriptionManager; -use PhpList\RestBundle\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Validator\RequestValidator; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; @@ -30,165 +26,17 @@ class SubscriptionController extends BaseController { private SubscriptionManager $subscriptionManager; - private SubscriberNormalizer $subscriberNormalizer; private SubscriptionNormalizer $subscriptionNormalizer; - private PaginatedDataProvider $paginatedProvider; public function __construct( Authentication $authentication, RequestValidator $validator, SubscriptionManager $subscriptionManager, - SubscriberNormalizer $subscriberNormalizer, SubscriptionNormalizer $subscriptionNormalizer, - PaginatedDataProvider $paginatedProvider, ) { parent::__construct($authentication, $validator); $this->subscriptionManager = $subscriptionManager; - $this->subscriberNormalizer = $subscriberNormalizer; $this->subscriptionNormalizer = $subscriptionNormalizer; - $this->paginatedProvider = $paginatedProvider; - } - - #[Route('/{listId}/subscribers', name: 'get_subscriber_from_list', methods: ['GET'])] - #[OA\Get( - path: '/lists/{listId}/subscribers', - description: 'Returns a JSON list of all subscribers for a subscriber list.', - summary: 'Gets a list of all subscribers of a subscriber list.', - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'session', - description: 'Session ID obtained from authentication', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'listId', - description: 'List ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'after_id', - description: 'Last id (starting from 0)', - in: 'query', - required: false, - schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) - ), - new OA\Parameter( - name: 'limit', - description: 'Number of results per page', - in: 'query', - required: false, - schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'items', - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Subscriber') - ), - new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') - ], - type: 'object' - ) - ), - 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 getListMembers( - Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, - ): JsonResponse { - $this->requireAuthentication($request); - - if (!$list) { - throw $this->createNotFoundException('Subscriber list not found.'); - } - - return $this->json( - $this->paginatedProvider->getPaginatedList( - $request, - $this->subscriberNormalizer, - Subscriber::class, - (new SubscriberFilter())->setListId($list->getId()) - ), - Response::HTTP_OK - ); - } - - #[Route('/{listId}/subscribers/count', name: 'get_subscribers_count_from_list', methods: ['GET'])] - #[OA\Get( - path: '/lists/{listId}/count', - description: 'Returns a count of all subscribers in a given list.', - summary: 'Gets the total number of subscribers of a list', - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'session', - description: 'Session ID obtained from authentication', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'listId', - description: 'List ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'subscribers_count', - type: 'integer', - example: 42 - ) - ], - type: 'object' - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ) - ] - )] - public function getSubscribersCount( - Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, - ): JsonResponse { - $this->requireAuthentication($request); - - if (!$list) { - throw $this->createNotFoundException('Subscriber list not found.'); - } - - return $this->json(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); } #[Route('/{listId}/subscribers', name: 'create_subscription', methods: ['POST'])] diff --git a/tests/Integration/Controller/ListMembersControllerTest.php b/tests/Integration/Controller/ListMembersControllerTest.php new file mode 100644 index 0000000..1586ba3 --- /dev/null +++ b/tests/Integration/Controller/ListMembersControllerTest.php @@ -0,0 +1,93 @@ +get(ListMembersController::class) + ); + } + + public function testGetSubscribersWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([SubscriberListFixture::class]); + self::getClient()->request('GET', '/api/v2/lists/1/subscribers'); + $this->assertHttpForbidden(); + } + + public function testGetSubscribersWithSessionReturnsList(): void + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + $this->authenticatedJsonRequest('GET', '/api/v2/lists/2/subscribers'); + $this->assertHttpOkay(); + } + + public function testGetSubscribersCountWithSessionReturnsCorrectCount(): void + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + $this->authenticatedJsonRequest('GET', '/api/v2/lists/2/subscribers/count'); + $data = $this->getDecodedJsonResponseContent(); + self::assertSame(2, $data['subscribers_count']); + } + + public function testGetListSubscribersCountForExistingListWithoutSessionKeyReturnsForbiddenStatus() + { + $this->loadFixtures([SubscriberListFixture::class]); + + self::getClient()->request('get', '/api/v2/lists/1/subscribers/count'); + + $this->assertHttpForbidden(); + } + + public function testGetListSubscribersCountForExistingListWithExpiredSessionKeyReturnsForbiddenStatus() + { + $this->loadFixtures([ + SubscriberListFixture::class, + AdministratorFixture::class, + AdministratorTokenFixture::class, + ]); + + self::getClient()->request( + 'get', + '/api/v2/lists/1/subscribers/count', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'cfdf64eecbbf336628b0f3071adba764'] + ); + + $this->assertHttpForbidden(); + } + + public function testGetSubscribersCountForEmptyListWithValidSession() + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + + $this->authenticatedJsonRequest('get', '/api/v2/lists/3/subscribers/count'); + $responseData = $this->getDecodedJsonResponseContent(); + + self::assertSame(0, $responseData['subscribers_count']); + } + + public function testGetSubscribersCountForListWithValidSession() + { + $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); + + $this->authenticatedJsonRequest('get', '/api/v2/lists/2/subscribers/count'); + $responseData = $this->getDecodedJsonResponseContent(); + + self::assertSame(2, $responseData['subscribers_count']); + } +} diff --git a/tests/Integration/Controller/SubscriptionControllerTest.php b/tests/Integration/Controller/SubscriptionControllerTest.php index c8b73fc..f43d396 100644 --- a/tests/Integration/Controller/SubscriptionControllerTest.php +++ b/tests/Integration/Controller/SubscriptionControllerTest.php @@ -21,28 +21,6 @@ public function testControllerIsAvailableViaContainer(): void ); } - public function testGetSubscribersWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([SubscriberListFixture::class]); - self::getClient()->request('GET', '/api/v2/lists/1/subscribers'); - $this->assertHttpForbidden(); - } - - public function testGetSubscribersWithSessionReturnsList(): void - { - $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); - $this->authenticatedJsonRequest('GET', '/api/v2/lists/2/subscribers'); - $this->assertHttpOkay(); - } - - public function testGetSubscribersCountWithSessionReturnsCorrectCount(): void - { - $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); - $this->authenticatedJsonRequest('GET', '/api/v2/lists/2/subscribers/count'); - $data = $this->getDecodedJsonResponseContent(); - self::assertSame(2, $data['subscribers_count']); - } - public function testCreateSubscriptionWithValidEmailsReturns201(): void { $this->loadFixtures([ @@ -75,34 +53,6 @@ public function testDeleteSubscriptionForUnknownEmailReturnsValidationError(): v } - public function testGetListSubscribersCountForExistingListWithoutSessionKeyReturnsForbiddenStatus() - { - $this->loadFixtures([SubscriberListFixture::class]); - - self::getClient()->request('get', '/api/v2/lists/1/subscribers/count'); - - $this->assertHttpForbidden(); - } - - public function testGetListSubscribersCountForExistingListWithExpiredSessionKeyReturnsForbiddenStatus() - { - $this->loadFixtures([ - SubscriberListFixture::class, - AdministratorFixture::class, - AdministratorTokenFixture::class, - ]); - - self::getClient()->request( - 'get', - '/api/v2/lists/1/subscribers/count', - [], - [], - ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'cfdf64eecbbf336628b0f3071adba764'] - ); - - $this->assertHttpForbidden(); - } - public function testGetListSubscribersCountWithCurrentSessionKeyForExistingListReturnsOkayStatus() { $this->loadFixtures([SubscriberListFixture::class]); @@ -111,24 +61,4 @@ public function testGetListSubscribersCountWithCurrentSessionKeyForExistingListR $this->assertHttpOkay(); } - - public function testGetSubscribersCountForEmptyListWithValidSession() - { - $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); - - $this->authenticatedJsonRequest('get', '/api/v2/lists/3/subscribers/count'); - $responseData = $this->getDecodedJsonResponseContent(); - - self::assertSame(0, $responseData['subscribers_count']); - } - - public function testGetSubscribersCountForListWithValidSession() - { - $this->loadFixtures([SubscriberListFixture::class, SubscriberFixture::class, SubscriptionFixture::class]); - - $this->authenticatedJsonRequest('get', '/api/v2/lists/2/subscribers/count'); - $responseData = $this->getDecodedJsonResponseContent(); - - self::assertSame(2, $responseData['subscribers_count']); - } } From 5f68e768332914729c0daff2732cc23f55978d54 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 4 May 2025 21:12:29 +0400 Subject: [PATCH 62/62] ISSUE-345: add tests --- composer.json | 2 +- tests/Helpers/DummyPaginatableRepository.php | 25 +++++ tests/Helpers/DummyRepository.php | 11 +++ .../EventListener/ExceptionListenerTest.php | 78 +++++++++++++++ .../EventListener/ResponseListenerTest.php | 46 +++++++++ .../ViewHandler}/SecuredViewHandlerTest.php | 2 +- .../Controller/SessionControllerTest.php | 42 --------- .../AdministratorNormalizerTest.php | 55 +++++++++++ .../CursorPaginationNormalizerTest.php | 74 +++++++++++++++ .../PaginationCursorRequestFactoryTest.php | 50 ++++++++++ .../Manager/AdministratorManagerTest.php | 94 +++++++++++++++++++ .../Provider/PaginatedDataProviderTest.php | 93 ++++++++++++++++++ .../ContainsPlaceholderValidatorTest.php | 51 ++++++++++ .../UniqueLoginNameValidatorTest.php | 81 ++++++++++++++++ 14 files changed, 660 insertions(+), 44 deletions(-) create mode 100644 tests/Helpers/DummyPaginatableRepository.php create mode 100644 tests/Helpers/DummyRepository.php create mode 100644 tests/Integration/EventListener/ExceptionListenerTest.php create mode 100644 tests/Integration/EventListener/ResponseListenerTest.php rename tests/{System/Controller => Integration/ViewHandler}/SecuredViewHandlerTest.php (92%) delete mode 100644 tests/System/Controller/SessionControllerTest.php create mode 100644 tests/Unit/Serializer/AdministratorNormalizerTest.php create mode 100644 tests/Unit/Serializer/CursorPaginationNormalizerTest.php create mode 100644 tests/Unit/Service/Factory/PaginationCursorRequestFactoryTest.php create mode 100644 tests/Unit/Service/Manager/AdministratorManagerTest.php create mode 100644 tests/Unit/Service/Provider/PaginatedDataProviderTest.php create mode 100644 tests/Unit/Validator/Constraint/ContainsPlaceholderValidatorTest.php create mode 100644 tests/Unit/Validator/Constraint/UniqueLoginNameValidatorTest.php diff --git a/composer.json b/composer.json index 06c8ac9..1d4c9f0 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-ISSUE-345", + "phplist/core": "v5.0.0-alpha6", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", diff --git a/tests/Helpers/DummyPaginatableRepository.php b/tests/Helpers/DummyPaginatableRepository.php new file mode 100644 index 0000000..78af42a --- /dev/null +++ b/tests/Helpers/DummyPaginatableRepository.php @@ -0,0 +1,25 @@ + 1, 'name' => 'Item 1'], + (object)['id' => 2, 'name' => 'Item 2'], + ]; + } + + public function count(array $criteria = []): int + { + return 10; + } +} diff --git a/tests/Helpers/DummyRepository.php b/tests/Helpers/DummyRepository.php new file mode 100644 index 0000000..1491bcf --- /dev/null +++ b/tests/Helpers/DummyRepository.php @@ -0,0 +1,11 @@ +createMock(HttpKernelInterface::class); + $request = new Request(); + return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + } + + public function testAccessDeniedExceptionHandled(): void + { + $listener = new ExceptionListener(); + $event = $this->createExceptionEvent(new AccessDeniedHttpException('Forbidden')); + + $listener->onKernelException($event); + $response = $event->getResponse(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(['message' => 'Forbidden'], json_decode($response->getContent(), true)); + } + + public function testHttpExceptionHandled(): void + { + $listener = new ExceptionListener(); + $event = $this->createExceptionEvent(new NotFoundHttpException('Not found')); + + $listener->onKernelException($event); + $response = $event->getResponse(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(404, $response->getStatusCode()); + $this->assertEquals(['message' => 'Not found'], json_decode($response->getContent(), true)); + } + + public function testSubscriptionCreationExceptionHandled(): void + { + $listener = new ExceptionListener(); + $exception = new SubscriptionCreationException('Subscription error', 409); + $event = $this->createExceptionEvent($exception); + + $listener->onKernelException($event); + $response = $event->getResponse(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(409, $response->getStatusCode()); + $this->assertEquals(['message' => 'Subscription error'], json_decode($response->getContent(), true)); + } + + public function testGenericExceptionHandled(): void + { + $listener = new ExceptionListener(); + $event = $this->createExceptionEvent(new \RuntimeException('Something went wrong')); + + $listener->onKernelException($event); + $response = $event->getResponse(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(['message' => 'Something went wrong'], json_decode($response->getContent(), true)); + } +} diff --git a/tests/Integration/EventListener/ResponseListenerTest.php b/tests/Integration/EventListener/ResponseListenerTest.php new file mode 100644 index 0000000..709291a --- /dev/null +++ b/tests/Integration/EventListener/ResponseListenerTest.php @@ -0,0 +1,46 @@ +createMock(HttpKernelInterface::class); + $request = new Request(); + $response = new JsonResponse(['data' => 'test']); + + $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response); + $listener->onKernelResponse($event); + + $this->assertEquals('nosniff', $response->headers->get('X-Content-Type-Options')); + $this->assertEquals("default-src 'none'", $response->headers->get('Content-Security-Policy')); + $this->assertEquals('DENY', $response->headers->get('X-Frame-Options')); + } + + public function testNonJsonResponseDoesNotGetHeaders(): void + { + $listener = new ResponseListener(); + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = new Request(); + $response = new Response('OK'); + + $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response); + $listener->onKernelResponse($event); + + $this->assertFalse($response->headers->has('X-Content-Type-Options')); + $this->assertFalse($response->headers->has('Content-Security-Policy')); + $this->assertFalse($response->headers->has('X-Frame-Options')); + } +} diff --git a/tests/System/Controller/SecuredViewHandlerTest.php b/tests/Integration/ViewHandler/SecuredViewHandlerTest.php similarity index 92% rename from tests/System/Controller/SecuredViewHandlerTest.php rename to tests/Integration/ViewHandler/SecuredViewHandlerTest.php index c823ed4..fbe0cc7 100644 --- a/tests/System/Controller/SecuredViewHandlerTest.php +++ b/tests/Integration/ViewHandler/SecuredViewHandlerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\RestBundle\Tests\System\Controller; +namespace PhpList\RestBundle\Tests\Integration\ViewHandler; use PhpList\RestBundle\Tests\Integration\Controller\AbstractTestController; diff --git a/tests/System/Controller/SessionControllerTest.php b/tests/System/Controller/SessionControllerTest.php deleted file mode 100644 index 29ae7e3..0000000 --- a/tests/System/Controller/SessionControllerTest.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ -class SessionControllerTest extends AbstractTestController -{ - use SymfonyServerTrait; - - public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() - { - $loginName = 'john.doe'; - $password = 'a sandwich and a cup of coffee'; - $jsonData = ['loginName' => $loginName, 'password' => $password]; - - self::getClient()->request( - 'POST', - '/api/v2/sessions', - [], - [], - [], - json_encode($jsonData) - ); - self::assertSame(Response::HTTP_UNAUTHORIZED, self::getClient()->getResponse()->getStatusCode()); - self::assertSame( - [ - 'message' => 'Not authorized', - ], - json_decode(self::getClient()->getResponse()->getContent(), true) - ); - } -} diff --git a/tests/Unit/Serializer/AdministratorNormalizerTest.php b/tests/Unit/Serializer/AdministratorNormalizerTest.php new file mode 100644 index 0000000..adcb4c9 --- /dev/null +++ b/tests/Unit/Serializer/AdministratorNormalizerTest.php @@ -0,0 +1,55 @@ +createMock(Administrator::class); + $admin->method('getId')->willReturn(123); + $admin->method('getLoginName')->willReturn('admin'); + $admin->method('getEmail')->willReturn('admin@example.com'); + $admin->method('isSuperUser')->willReturn(true); + $admin->method('getCreatedAt')->willReturn(new DateTime('2024-01-01T10:00:00+00:00')); + + $normalizer = new AdministratorNormalizer(); + $data = $normalizer->normalize($admin); + + $this->assertIsArray($data); + $this->assertEquals([ + 'id' => 123, + 'login_name' => 'admin', + 'email' => 'admin@example.com', + 'super_admin' => true, + 'created_at' => '2024-01-01T10:00:00+00:00', + ], $data); + } + + public function testNormalizeThrowsOnInvalidObject(): void + { + $normalizer = new AdministratorNormalizer(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected an Administrator object.'); + + $normalizer->normalize(new \stdClass()); + } + + public function testSupportsNormalization(): void + { + $normalizer = new AdministratorNormalizer(); + + $admin = $this->createMock(Administrator::class); + $this->assertTrue($normalizer->supportsNormalization($admin)); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); + } +} diff --git a/tests/Unit/Serializer/CursorPaginationNormalizerTest.php b/tests/Unit/Serializer/CursorPaginationNormalizerTest.php new file mode 100644 index 0000000..49054e7 --- /dev/null +++ b/tests/Unit/Serializer/CursorPaginationNormalizerTest.php @@ -0,0 +1,74 @@ + 1, 'value' => 'A'], + ['id' => 2, 'value' => 'B'], + ]; + + $paginationResult = new CursorPaginationResult($items, limit: 2, total: 10); + $normalizer = new CursorPaginationNormalizer(); + + $result = $normalizer->normalize($paginationResult); + + $this->assertIsArray($result); + $this->assertEquals($items, $result['items']); + $this->assertEquals([ + 'total' => 10, + 'limit' => 2, + 'has_more' => true, + 'next_cursor' => 2, + ], $result['pagination']); + } + + public function testNormalizeWithFewerItemsThanLimit(): void + { + $items = [ + ['id' => 5, 'value' => 'X'], + ]; + + $paginationResult = new CursorPaginationResult($items, limit: 5, total: 3); + $normalizer = new CursorPaginationNormalizer(); + + $result = $normalizer->normalize($paginationResult); + + $this->assertFalse($result['pagination']['has_more']); + $this->assertEquals(5, $result['pagination']['next_cursor']); + } + + public function testNormalizeWithEmptyItems(): void + { + $paginationResult = new CursorPaginationResult([], limit: 5, total: 0); + $normalizer = new CursorPaginationNormalizer(); + + $result = $normalizer->normalize($paginationResult); + + $this->assertSame([], $result['items']); + $this->assertSame([ + 'total' => 0, + 'limit' => 5, + 'has_more' => false, + 'next_cursor' => null, + ], $result['pagination']); + } + + public function testSupportsNormalization(): void + { + $normalizer = new CursorPaginationNormalizer(); + + $dto = new CursorPaginationResult([], 0, 10); + $this->assertTrue($normalizer->supportsNormalization($dto)); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); + } +} diff --git a/tests/Unit/Service/Factory/PaginationCursorRequestFactoryTest.php b/tests/Unit/Service/Factory/PaginationCursorRequestFactoryTest.php new file mode 100644 index 0000000..1cb6b35 --- /dev/null +++ b/tests/Unit/Service/Factory/PaginationCursorRequestFactoryTest.php @@ -0,0 +1,50 @@ + '10', + 'limit' => '50', + ]); + + $factory = new PaginationCursorRequestFactory(); + $paginationRequest = $factory->fromRequest($request); + + $this->assertSame(10, $paginationRequest->afterId); + $this->assertSame(50, $paginationRequest->limit); + } + + public function testFromRequestWithMissingLimit(): void + { + $request = new Request(query: [ + 'after_id' => '5', + ]); + + $factory = new PaginationCursorRequestFactory(); + $paginationRequest = $factory->fromRequest($request); + + $this->assertSame(5, $paginationRequest->afterId); + $this->assertSame(25, $paginationRequest->limit); + } + + public function testFromRequestWithDefaults(): void + { + $request = new Request(); + + $factory = new PaginationCursorRequestFactory(); + $paginationRequest = $factory->fromRequest($request); + + $this->assertSame(0, $paginationRequest->afterId); + $this->assertSame(25, $paginationRequest->limit); + } +} diff --git a/tests/Unit/Service/Manager/AdministratorManagerTest.php b/tests/Unit/Service/Manager/AdministratorManagerTest.php new file mode 100644 index 0000000..39f441b --- /dev/null +++ b/tests/Unit/Service/Manager/AdministratorManagerTest.php @@ -0,0 +1,94 @@ +createMock(EntityManagerInterface::class); + $hashGenerator = $this->createMock(HashGenerator::class); + + $dto = new CreateAdministratorRequest(); + $dto->loginName = 'admin'; + $dto->email = 'admin@example.com'; + $dto->superUser = true; + $dto->password = 'securepass'; + + $hashGenerator->expects($this->once()) + ->method('createPasswordHash') + ->with('securepass') + ->willReturn('hashed_pass'); + + $entityManager->expects($this->once())->method('persist'); + $entityManager->expects($this->once())->method('flush'); + + $manager = new AdministratorManager($entityManager, $hashGenerator); + $admin = $manager->createAdministrator($dto); + + $this->assertInstanceOf(Administrator::class, $admin); + $this->assertEquals('admin', $admin->getLoginName()); + $this->assertEquals('admin@example.com', $admin->getEmail()); + $this->assertEquals(true, $admin->isSuperUser()); + $this->assertEquals('hashed_pass', $admin->getPasswordHash()); + } + + public function testUpdateAdministrator(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $hashGenerator = $this->createMock(HashGenerator::class); + + $admin = new Administrator(); + $admin->setLoginName('old'); + $admin->setEmail('old@example.com'); + $admin->setSuperUser(false); + $admin->setPasswordHash('old_hash'); + + $dto = new UpdateAdministratorRequest(); + $dto->loginName = 'new'; + $dto->email = 'new@example.com'; + $dto->superAdmin = true; + $dto->password = 'newpass'; + + $hashGenerator->expects($this->once()) + ->method('createPasswordHash') + ->with('newpass') + ->willReturn('new_hash'); + + $entityManager->expects($this->once())->method('flush'); + + $manager = new AdministratorManager($entityManager, $hashGenerator); + $manager->updateAdministrator($admin, $dto); + + $this->assertEquals('new', $admin->getLoginName()); + $this->assertEquals('new@example.com', $admin->getEmail()); + $this->assertTrue($admin->isSuperUser()); + $this->assertEquals('new_hash', $admin->getPasswordHash()); + } + + public function testDeleteAdministrator(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $hashGenerator = $this->createMock(HashGenerator::class); + + $admin = $this->createMock(Administrator::class); + + $entityManager->expects($this->once())->method('remove')->with($admin); + $entityManager->expects($this->once())->method('flush'); + + $manager = new AdministratorManager($entityManager, $hashGenerator); + $manager->deleteAdministrator($admin); + + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Service/Provider/PaginatedDataProviderTest.php b/tests/Unit/Service/Provider/PaginatedDataProviderTest.php new file mode 100644 index 0000000..1175354 --- /dev/null +++ b/tests/Unit/Service/Provider/PaginatedDataProviderTest.php @@ -0,0 +1,93 @@ + 0, + 'limit' => 2, + ]); + + $paginationFactory = $this->createMock(PaginationCursorRequestFactory::class); + $paginationFactory->method('fromRequest') + ->willReturn(new PaginationCursorRequest(0, 2)); + + $entityManager = $this->createMock(EntityManagerInterface::class); + + $repository = $this->createMock(DummyPaginatableRepository::class); + $entityManager->method('getRepository')->willReturn($repository); + $repository->expects($this->once()) + ->method('getFilteredAfterId') + ->with(0, 2) + ->willReturn([ + (object)['id' => 1, 'name' => 'Item 1'], + (object)['id' => 2, 'name' => 'Item 2'], + ]); + + $repository->expects($this->once()) + ->method('count') + ->willReturn(10); + + $entityManager->method('getRepository') + ->willReturn($repository); + + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->method('normalize') + ->willReturnCallback(fn($item) => (array)$item); + + $paginationNormalizer = $this->createMock(CursorPaginationNormalizer::class); + $paginationNormalizer->expects($this->once()) + ->method('normalize') + ->with($this->isInstanceOf(CursorPaginationResult::class)) + ->willReturn(['items' => [], 'pagination' => []]); + + $provider = new PaginatedDataProvider($paginationNormalizer, $paginationFactory, $entityManager); + + $result = $provider->getPaginatedList($request, $normalizer, 'Some\\Entity\\Class'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('pagination', $result); + } + + public function testThrowsIfRepositoryIsNotPaginatable(): void + { + $request = new Request(); + + $paginationFactory = $this->createMock(PaginationCursorRequestFactory::class); + $paginationFactory->method('fromRequest') + ->willReturn(new PaginationCursorRequest(0, 10)); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $repository = $this->createMock(DummyRepository::class); + $entityManager->method('getRepository')->willReturn($repository); + + $normalizer = $this->createMock(NormalizerInterface::class); + $paginationNormalizer = $this->createMock(CursorPaginationNormalizer::class); + + $provider = new PaginatedDataProvider($paginationNormalizer, $paginationFactory, $entityManager); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Repository not found'); + + $provider->getPaginatedList($request, $normalizer, 'NonPaginatableClass'); + } +} diff --git a/tests/Unit/Validator/Constraint/ContainsPlaceholderValidatorTest.php b/tests/Unit/Validator/Constraint/ContainsPlaceholderValidatorTest.php new file mode 100644 index 0000000..a4249db --- /dev/null +++ b/tests/Unit/Validator/Constraint/ContainsPlaceholderValidatorTest.php @@ -0,0 +1,51 @@ +createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $validator = new ContainsPlaceholderValidator(); + $validator->initialize($context); + + $constraint = new ContainsPlaceholder(['placeholder' => '[CONTENT]']); + $validator->validate('[CONTENT]', $constraint); + + $this->assertTrue(true); + } + + public function testValidateWithMissingPlaceholder(): void + { + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('setParameter')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->with('The content must include the "{{ placeholder }}" placeholder.') + ->willReturn($builder); + + $validator = new ContainsPlaceholderValidator(); + $validator->initialize($context); + + $constraint = new ContainsPlaceholder([ + 'placeholder' => '[CONTENT]', + 'message' => 'The content must include the "{{ placeholder }}" placeholder.' + ]); + + $validator->validate('no placeholder here', $constraint); + } +} diff --git a/tests/Unit/Validator/Constraint/UniqueLoginNameValidatorTest.php b/tests/Unit/Validator/Constraint/UniqueLoginNameValidatorTest.php new file mode 100644 index 0000000..af86b85 --- /dev/null +++ b/tests/Unit/Validator/Constraint/UniqueLoginNameValidatorTest.php @@ -0,0 +1,81 @@ +createMock(AdministratorRepository::class); + $repository->method('findOneBy')->willReturn(null); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $validator = new UniqueLoginNameValidator($repository); + $validator->initialize($context); + + $constraint = new UniqueLoginName(); + $validator->validate('new_login', $constraint); + + $this->assertTrue(true); + } + + public function testValidateThrowsConflictForExistingLoginName(): void + { + $admin = $this->createMock(Administrator::class); + $admin->method('getId')->willReturn(2); + + $repository = $this->createMock(AdministratorRepository::class); + $repository->method('findOneBy')->willReturn($admin); + + $context = $this->createMock(ExecutionContextInterface::class); + $dto = new class { + public $administratorId = 1; + }; + + $context->method('getObject')->willReturn($dto); + + $validator = new UniqueLoginNameValidator($repository); + $validator->initialize($context); + + $this->expectException(ConflictHttpException::class); + + $constraint = new UniqueLoginName(); + $validator->validate('duplicate_login', $constraint); + } + + public function testValidateSkipsConflictIfSameAdministrator(): void + { + $admin = $this->createMock(Administrator::class); + $admin->method('getId')->willReturn(1); + + $repository = $this->createMock(AdministratorRepository::class); + $repository->method('findOneBy')->willReturn($admin); + + $context = $this->createMock(ExecutionContextInterface::class); + $dto = new class { + public $administratorId = 1; + }; + + $context->method('getObject')->willReturn($dto); + + $validator = new UniqueLoginNameValidator($repository); + $validator->initialize($context); + + $constraint = new UniqueLoginName(); + $validator->validate('same_login', $constraint); + + $this->assertTrue(true); + } +}