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 diff --git a/composer.json b/composer.json index 095b5a6..1d4c9f0 100644 --- a/composer.json +++ b/composer.json @@ -31,11 +31,12 @@ }, "require": { "php": "^8.1", - "phplist/core": "v5.0.0-alpha3", + "phplist/core": "v5.0.0-alpha6", "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 36264ec..4d668a3 100644 --- a/config/services.yml +++ b/config/services.yml @@ -1,3 +1,5 @@ +imports: + - { resource: 'services/*.yml' } services: Psr\Container\ContainerInterface: alias: 'service_container' @@ -8,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 @@ -24,17 +22,5 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Repository\Messaging\SubscriberListRepository: - 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' ] - autowire: true + GuzzleHttp\ClientInterface: + class: GuzzleHttp\Client 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/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/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..7f42416 --- /dev/null +++ b/config/services/managers.yml @@ -0,0 +1,37 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Service\Manager\SubscriberManager: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Manager\SessionManager: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Manager\SubscriberListManager: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Service\Manager\SubscriptionManager: + autowire: true + autoconfigure: true + + 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 + + PhpList\RestBundle\Service\Manager\AdministratorManager: + autowire: true + autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml new file mode 100644 index 0000000..ab3d3d7 --- /dev/null +++ b/config/services/normalizers.yml @@ -0,0 +1,47 @@ +services: + _defaults: + autowire: true + 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' + + PhpList\RestBundle\Serializer\SubscriberNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Serializer\AdministratorTokenNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Serializer\SubscriberListNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Serializer\SubscriptionNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + 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 + + 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..49c7ff7 --- /dev/null +++ b/config/services/providers.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Service\Provider\PaginatedDataProvider: + autowire: true + autoconfigure: true diff --git a/config/services/validators.yml b/config/services/validators.yml new file mode 100644 index 0000000..d1d519c --- /dev/null +++ b/config/services/validators.yml @@ -0,0 +1,36 @@ +services: + PhpList\RestBundle\Validator\RequestValidator: + autowire: true + autoconfigure: true + public: false + + PhpList\RestBundle\Validator\Constraint\UniqueEmailValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Validator\Constraint\EmailExistsValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Validator\Constraint\TemplateExistsValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Validator\TemplateLinkValidator: + autowire: true + autoconfigure: true + + PhpList\RestBundle\Validator\TemplateImageValidator: + autowire: true + autoconfigure: true + + 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 new file mode 100644 index 0000000..1adb2be --- /dev/null +++ b/src/Controller/AdministratorController.php @@ -0,0 +1,273 @@ +administratorManager = $administratorManager; + $this->normalizer = $normalizer; + $this->paginatedProvider = $paginatedProvider; + } + + #[Route('', name: 'get_administrators', methods: ['GET'])] + #[OA\Get( + 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 $this->json( + $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Administrator::class), + Response::HTTP_OK + ); + } + + #[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/Administrator') + ), + new OA\Response( + response: 400, + description: 'Invalid input' + ) + ] + )] + public function createAdministrator( + Request $request, + 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 $this->json($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( + Request $request, + #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$administrator) { + throw $this->createNotFoundException('Administrator not found.'); + } + $json = $this->normalizer->normalize($administrator, 'json'); + + return $this->json($json, Response::HTTP_OK); + } + + #[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, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$administrator) { + throw $this->createNotFoundException('Administrator not found.'); + } + /** @var UpdateAdministratorRequest $dto */ + $dto = $this->validator->validate($request, UpdateAdministratorRequest::class); + $this->administratorManager->updateAdministrator($administrator, $dto); + + return $this->json(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( + Request $request, + #[MapEntity(mapping: ['administratorId' => 'id'])] ?Administrator $administrator + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$administrator) { + throw $this->createNotFoundException('Administrator not found.'); + } + $this->administratorManager->deleteAdministrator($administrator); + + return $this->json(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 new file mode 100644 index 0000000..73789bb --- /dev/null +++ b/src/Controller/CampaignController.php @@ -0,0 +1,355 @@ + + */ +#[Route('/campaigns')] +class CampaignController extends BaseController +{ + private MessageNormalizer $normalizer; + private MessageManager $messageManager; + private PaginatedDataProvider $paginatedProvider; + + public function __construct( + Authentication $authentication, + RequestValidator $validator, + MessageNormalizer $normalizer, + MessageManager $messageManager, + PaginatedDataProvider $paginatedProvider, + ) { + parent::__construct($authentication, $validator); + $this->normalizer = $normalizer; + $this->messageManager = $messageManager; + $this->paginatedProvider = $paginatedProvider; + } + + #[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' + ) + ), + 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/Message') + ), + 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') + ) + ] + )] + public function getMessages(Request $request): JsonResponse + { + $authUer = $this->requireAuthentication($request); + + $filter = (new MessageFilter())->setOwner($authUer); + + return $this->json( + $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter), + 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' + ) + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Message') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$message) { + throw $this->createNotFoundException('Campaign not found.'); + } + + return $this->json($this->normalizer->normalize($message), 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( + required: ['content', 'format', 'metadata', 'schedule', 'options'], + properties: [ + new OA\Property(property: 'template_id', type: 'integer', example: 1), + 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' + ) + ), + 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 $this->json($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( + required: ['content', 'format', 'schedule', 'options'], + properties: [ + new OA\Property(property: 'template_id', type: 'integer', example: 1), + 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' + ) + ), + 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 = null, + ): JsonResponse { + $authUser = $this->requireAuthentication($request); + + if (!$message) { + throw $this->createNotFoundException('Campaign not found.'); + } + /** @var UpdateMessageRequest $updateMessageRequest */ + $updateMessageRequest = $this->validator->validate($request, UpdateMessageRequest::class); + $data = $this->messageManager->updateMessage($updateMessageRequest, $message, $authUser); + + return $this->json($this->normalizer->normalize($data), 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 = null + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$message) { + throw $this->createNotFoundException('Campaign not found.'); + } + + $this->messageManager->delete($message); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/ListController.php b/src/Controller/ListController.php index a09dfb1..c7af395 100644 --- a/src/Controller/ListController.php +++ b/src/Controller/ListController.php @@ -4,20 +4,19 @@ namespace PhpList\RestBundle\Controller; -use PhpList\Core\Domain\Model\Messaging\SubscriberList; -use PhpList\Core\Domain\Repository\Subscription\SubscriberRepository; -use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Repository\Messaging\SubscriberListRepository; +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\Manager\SubscriberListManager; +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\Routing\Attribute\Route; -use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -use Symfony\Component\Serializer\SerializerInterface; -use OpenApi\Attributes as OA; /** * This controller provides REST API access to subscriber lists. @@ -26,27 +25,27 @@ * @author Xheni Myrtaj * @author Tatevik Grigoryan */ -class ListController extends AbstractController +#[Route('/lists')] +class ListController extends BaseController { - use AuthenticationTrait; - - private SubscriberListRepository $subscriberListRepository; - private SubscriberRepository $subscriberRepository; - private SerializerInterface $serializer; + private SubscriberListNormalizer $normalizer; + private SubscriberListManager $subscriberListManager; + private PaginatedDataProvider $paginatedDataProvider; public function __construct( Authentication $authentication, - SubscriberListRepository $repository, - SubscriberRepository $subscriberRepository, - SerializerInterface $serializer + RequestValidator $validator, + SubscriberListNormalizer $normalizer, + SubscriberListManager $subscriberListManager, + PaginatedDataProvider $paginatedDataProvider, ) { - $this->authentication = $authentication; - $this->subscriberListRepository = $repository; - $this->subscriberRepository = $subscriberRepository; - $this->serializer = $serializer; + parent::__construct($authentication, $validator); + $this->normalizer = $normalizer; + $this->subscriberListManager = $subscriberListManager; + $this->paginatedDataProvider = $paginatedDataProvider; } - #[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.', @@ -61,66 +60,56 @@ 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: [ new OA\Response( 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' - ) - ) - ), - 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.' - ) + 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( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ) ] )] public function getLists(Request $request): JsonResponse { $this->requireAuthentication($request); - $data = $this->subscriberListRepository->findAll(); - $json = $this->serializer->serialize($data, 'json', [ - AbstractNormalizer::GROUPS => 'SubscriberList', - ]); - return new JsonResponse($json, Response::HTTP_OK, [], true); + return $this->json( + $this->paginatedDataProvider->getPaginatedList($request, $this->normalizer, SubscriberList::class), + Response::HTTP_OK + ); } - #[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.', @@ -146,33 +135,12 @@ 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, 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, @@ -192,17 +160,18 @@ 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); - $json = $this->serializer->serialize($list, 'json', [ - AbstractNormalizer::GROUPS => 'SubscriberList', - ]); - return new JsonResponse($json, Response::HTTP_OK, [], true); + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + + return $this->json($this->normalizer->normalize($list), Response::HTTP_OK); } - #[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,49 +201,48 @@ 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, 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') ) ] )] public function deleteList( Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null ): JsonResponse { $this->requireAuthentication($request); - $this->subscriberListRepository->remove($list); + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + + $this->subscriberListManager->delete($list); - return new JsonResponse(null, Response::HTTP_NO_CONTENT, [], false); + return $this->json(null, Response::HTTP_NO_CONTENT); } - #[Route('/lists/{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.', + #[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), + ] + ) + ), tags: ['lists'], parameters: [ new OA\Parameter( @@ -282,147 +250,37 @@ public function deleteList( 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') + schema: new OA\Schema( + type: 'string' + ) ) ], responses: [ new OA\Response( - response: 200, + response: 201, 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' - ) - ) + content: new OA\JsonContent(ref: '#/components/schemas/SubscriberList') ), 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' - ) - ) - ] - )] - public function getListMembers( - Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list - ): JsonResponse { - $this->requireAuthentication($request); - - $subscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); - - $json = $this->serializer->serialize($subscribers, 'json', [ - AbstractNormalizer::GROUPS => 'SubscriberListMembers', - ]); - - return new JsonResponse($json, Response::HTTP_OK, [], true); - } - - #[Route('/lists/{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(ref: '#/components/schemas/UnauthorizedResponse') ), new OA\Response( - response: 403, + response: 422, 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/ValidationErrorResponse') + ), ] )] - public function getSubscribersCount( - Request $request, - #[MapEntity(mapping: ['listId' => 'id'])] SubscriberList $list - ): JsonResponse { - $this->requireAuthentication($request); - $json = $this->serializer->serialize(count($list->getSubscribers()), 'json'); + public function createList(Request $request, SubscriberListNormalizer $normalizer): JsonResponse + { + $authUser = $this->requireAuthentication($request); + + /** @var CreateSubscriberListRequest $subscriberListRequest */ + $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); + $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest, $authUser); - return new JsonResponse($json, Response::HTTP_OK, [], true); + return $this->json($normalizer->normalize($data), Response::HTTP_CREATED); } } 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/SessionController.php b/src/Controller/SessionController.php index 4509e67..9755d69 100644 --- a/src/Controller/SessionController.php +++ b/src/Controller/SessionController.php @@ -4,55 +4,42 @@ namespace PhpList\RestBundle\Controller; -use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use PhpList\Core\Domain\Model\Identity\Administrator; +use OpenApi\Attributes as OA; 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 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\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; /** * This controller provides methods to create and destroy REST API sessions. * * @author Oliver Klee + * @author Tatevik Grigoryan */ -class SessionController extends AbstractController +#[Route('/sessions')] +class SessionController extends BaseController { - 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 + RequestValidator $validator, + SessionManager $sessionManager, ) { - $this->authentication = $authentication; - $this->administratorRepository = $administratorRepository; - $this->tokenRepository = $tokenRepository; - $this->serializer = $serializer; + parent::__construct($authentication, $validator); + + $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.', @@ -84,15 +71,7 @@ public function __construct( 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, @@ -105,21 +84,17 @@ 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, + AdministratorTokenNormalizer $normalizer + ): JsonResponse { + /** @var CreateSessionRequest $createSessionRequest */ + $createSessionRequest = $this->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 $this->json($json, Response::HTTP_CREATED); } /** @@ -129,7 +104,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.', @@ -152,77 +127,30 @@ public function createSession(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.' - ) - ] - ) + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') ), 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 deleteAction( + 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->getAdministrator() !== $administrator) { - throw new AccessDeniedHttpException('You do not have access to this session.', null, 1519831644); - } - - $this->tokenRepository->remove($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 (!$token) { + throw $this->createNotFoundException('Token not found.'); } - if (empty($request->getPayload()->get('login_name')) || empty($request->getPayload()->get('password'))) { - throw new BadRequestHttpException('Incomplete credentials', null, 1500562647); + if ($token->getAdministrator() !== $administrator) { + throw new AccessDeniedHttpException('You do not have access to this session.', null, 1519831644); } - } - /** - * @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); + $this->sessionManager->deleteSession($token); - return $token; + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/SubscriberController.php b/src/Controller/SubscriberController.php index 452ef42..7ed809d 100644 --- a/src/Controller/SubscriberController.php +++ b/src/Controller/SubscriberController.php @@ -4,38 +4,45 @@ namespace PhpList\RestBundle\Controller; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +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\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\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; /** * This controller provides REST API access to subscribers. * * @author Oliver Klee + * @author Tatevik Grigoryan */ -class SubscriberController extends AbstractController +#[Route('/subscribers')] +class SubscriberController extends BaseController { - use AuthenticationTrait; + private SubscriberManager $subscriberManager; + private SubscriberNormalizer $subscriberNormalizer; - private SubscriberRepository $subscriberRepository; - - public function __construct(Authentication $authentication, SubscriberRepository $repository) - { + public function __construct( + Authentication $authentication, + RequestValidator $validator, + SubscriberManager $subscriberManager, + SubscriberNormalizer $subscriberNormalizer, + ) { + parent::__construct($authentication, $validator); $this->authentication = $authentication; - $this->subscriberRepository = $repository; + $this->subscriberManager = $subscriberManager; + $this->subscriberNormalizer = $subscriberNormalizer; } - #[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).', @@ -66,99 +73,59 @@ public function __construct(Authentication $authentication, SubscriberRepository 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, 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, 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, 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 postAction(Request $request, SerializerInterface $serializer): JsonResponse + public function createSubscriber(Request $request): 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 CreateSubscriberRequest $subscriberRequest */ + $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); + $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest); - return new JsonResponse( - $serializer->serialize($subscriber, 'json'), - Response::HTTP_CREATED, - [], - true + return $this->json( + $this->subscriberNormalizer->normalize($subscriber, 'json'), + Response::HTTP_CREATED ); } - #[Route('/subscribers/{subscriberId}', name: 'get_subscriber_by_id', methods: ['GET'])] - #[OA\Get( + #[Route('/{subscriberId}', name: 'update_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['PUT'])] + #[OA\Put( path: '/subscribers/{subscriberId}', - description: 'Get subscriber date by id.', - summary: 'Get a subscriber', + 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: '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( @@ -180,113 +147,140 @@ public function postAction(Request $request, SerializerInterface $serializer): J 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, 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: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') ), new OA\Response( response: 404, - description: 'Not Found', + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') ) ] )] - public function getAction(Request $request, int $subscriberId, SerializerInterface $serializer): JsonResponse - { + public function updateSubscriber( + Request $request, + #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, + ): JsonResponse { $this->requireAuthentication($request); - $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); - if (!$subscriber) { - return new JsonResponse(['error' => 'Subscriber not found'], Response::HTTP_NOT_FOUND); + throw $this->createNotFoundException('Subscriber not found.'); } + /** @var UpdateSubscriberRequest $dto */ + $dto = $this->validator->validate($request, UpdateSubscriberRequest::class); + $subscriber = $this->subscriberManager->updateSubscriber($dto); - $data = $serializer->serialize($subscriber, 'json'); - - return new JsonResponse($data, Response::HTTP_OK, [], true); + return $this->json($this->subscriberNormalizer->normalize($subscriber, 'json'), Response::HTTP_OK); } - /** - * @param Request $request - * - * @return void - * - * @throws UnprocessableEntityHttpException - */ - private function validateSubscriber(Request $request): void + #[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: [ + 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(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function getSubscriber(Request $request, int $subscriberId): JsonResponse { - /** @var string[] $invalidFields */ - $invalidFields = []; - if (filter_var($request->getPayload()->get('email'), FILTER_VALIDATE_EMAIL) === false) { - $invalidFields[] = 'email'; - } + $this->requireAuthentication($request); - $booleanFields = ['request_confirmation', 'html_email']; - foreach ($booleanFields as $fieldKey) { - if ($request->getPayload()->get($fieldKey) !== null - && !is_bool($request->getPayload()->get($fieldKey)) - ) { - $invalidFields[] = $fieldKey; - } - } + $subscriber = $this->subscriberManager->getSubscriber($subscriberId); + + return $this->json($this->subscriberNormalizer->normalize($subscriber), Response::HTTP_OK); + } + + #[Route('/{subscriberId}', name: 'delete_subscriber', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])] + #[OA\Delete( + path: '/subscribers/{subscriberId}', + description: 'Delete subscriber by id.', + summary: 'Delete subscriber', + 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: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function deleteSubscriber( + Request $request, + #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, + ): JsonResponse { + $this->requireAuthentication($request); - if (!empty($invalidFields)) { - throw new UnprocessableEntityHttpException( - 'Some fields invalid:' . implode(', ', $invalidFields), - null, - 1513446736 - ); + if (!$subscriber) { + throw $this->createNotFoundException('Subscriber not found.'); } + $this->subscriberManager->deleteSubscriber($subscriber); + + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Controller/SubscriptionController.php b/src/Controller/SubscriptionController.php new file mode 100644 index 0000000..ea2dacc --- /dev/null +++ b/src/Controller/SubscriptionController.php @@ -0,0 +1,196 @@ + + */ +#[Route('/lists')] +class SubscriptionController extends BaseController +{ + private SubscriptionManager $subscriptionManager; + private SubscriptionNormalizer $subscriptionNormalizer; + + public function __construct( + Authentication $authentication, + RequestValidator $validator, + SubscriptionManager $subscriptionManager, + SubscriptionNormalizer $subscriptionNormalizer, + ) { + parent::__construct($authentication, $validator); + $this->subscriptionManager = $subscriptionManager; + $this->subscriptionNormalizer = $subscriptionNormalizer; + } + + #[Route('/{listId}/subscribers', name: 'create_subscription', methods: ['POST'])] + #[OA\Post( + 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: ['emails'], + properties: [ + new OA\Property( + property: 'emails', + type: 'array', + items: new OA\Items(type: 'string', format: 'email'), + example: ['test1@example.com', 'test2@example.com'] + ), + ] + ) + ), + 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: 201, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + 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: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + 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, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + + /** @var SubscriptionRequest $subscriptionRequest */ + $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); + $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); + $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); + + return $this->json($normalized, Response::HTTP_CREATED); + } + + #[Route('/{listId}/subscribers', name: 'delete_subscription', methods: ['DELETE'])] + #[OA\Delete( + path: '/lists/{listId}/subscribers', + description: 'Delete subscription.', + summary: 'Delete subscription', + 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: 'emails', + description: 'emails of subscribers to delete from list.', + in: 'query', + 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: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function deleteSubscriptions( + Request $request, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, + ): JsonResponse { + $this->requireAuthentication($request); + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + $subscriptionRequest = new SubscriptionRequest(); + $subscriptionRequest->emails = $request->query->all('emails'); + + /** @var SubscriptionRequest $subscriptionRequest */ + $subscriptionRequest = $this->validator->validateDto($subscriptionRequest); + $this->subscriptionManager->deleteSubscriptions($list, $subscriptionRequest->emails); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/TemplateController.php b/src/Controller/TemplateController.php new file mode 100644 index 0000000..7914dd3 --- /dev/null +++ b/src/Controller/TemplateController.php @@ -0,0 +1,325 @@ + + */ +#[Route('/templates')] +class TemplateController extends BaseController +{ + private TemplateNormalizer $normalizer; + private TemplateManager $templateManager; + private PaginatedDataProvider $paginatedDataProvider; + + public function __construct( + Authentication $authentication, + RequestValidator $validator, + TemplateNormalizer $normalizer, + TemplateManager $templateManager, + PaginatedDataProvider $paginatedDataProvider, + ) { + parent::__construct($authentication, $validator); + $this->normalizer = $normalizer; + $this->templateManager = $templateManager; + $this->paginatedDataProvider = $paginatedDataProvider; + } + + #[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' + ) + ), + 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/Template') + ), + 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') + ) + ] + )] + public function getTemplates(Request $request): JsonResponse + { + $this->requireAuthentication($request); + + return $this->json( + $this->paginatedDataProvider->getPaginatedList( + $request, + $this->normalizer, + Template::class, + ), + 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') + ), + 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 = null, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$template) { + throw $this->createNotFoundException('Template not found.'); + } + + return $this->json($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 $this->json( + $this->normalizer->normalize($this->templateManager->create($createTemplateRequest)), + 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 = null, + ): JsonResponse { + $this->requireAuthentication($request); + + if (!$template) { + throw $this->createNotFoundException('Template not found.'); + } + + $this->templateManager->delete($template); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/Traits/AuthenticationTrait.php b/src/Controller/Traits/AuthenticationTrait.php deleted file mode 100644 index 63961d2..0000000 --- a/src/Controller/Traits/AuthenticationTrait.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ -trait AuthenticationTrait -{ - /** - * @var Authentication - */ - private $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 - { - $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/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 @@ +user; + } + + public function getExisting(): ?Message + { + return $this->existing; + } +} 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/CreateAdministratorRequest.php b/src/Entity/Request/CreateAdministratorRequest.php new file mode 100644 index 0000000..71bf9d6 --- /dev/null +++ b/src/Entity/Request/CreateAdministratorRequest.php @@ -0,0 +1,30 @@ +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/Entity/Request/RequestInterface.php b/src/Entity/Request/RequestInterface.php new file mode 100644 index 0000000..5c13ace --- /dev/null +++ b/src/Entity/Request/RequestInterface.php @@ -0,0 +1,10 @@ + $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 new file mode 100644 index 0000000..4628421 --- /dev/null +++ b/src/Exception/SubscriptionCreationException.php @@ -0,0 +1,23 @@ +statusCode = $statusCode; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/src/OpenApi/SwaggerSchemasRequestDto.php b/src/OpenApi/SwaggerSchemasRequestDto.php new file mode 100644 index 0000000..813a89d --- /dev/null +++ b/src/OpenApi/SwaggerSchemasRequestDto.php @@ -0,0 +1,147 @@ +', + 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: 'user_selection', type: 'string', nullable: true), + ], + type: 'object' + ), + ], + 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 +{ +} diff --git a/src/Serializer/AdministratorNormalizer.php b/src/Serializer/AdministratorNormalizer.php new file mode 100644 index 0000000..1eb5327 --- /dev/null +++ b/src/Serializer/AdministratorNormalizer.php @@ -0,0 +1,40 @@ + $object->getId(), + 'login_name' => $object->getLoginName(), + 'email' => $object->getEmail(), + '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/AdministratorTokenNormalizer.php b/src/Serializer/AdministratorTokenNormalizer.php new file mode 100644 index 0000000..74a45da --- /dev/null +++ b/src/Serializer/AdministratorTokenNormalizer.php @@ -0,0 +1,35 @@ + $object->getId(), + 'key' => $object->getKey(), + 'expiry_date' => $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/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/MessageNormalizer.php b/src/Serializer/MessageNormalizer.php new file mode 100644 index 0000000..dd3c5ba --- /dev/null +++ b/src/Serializer/MessageNormalizer.php @@ -0,0 +1,72 @@ +getTemplate(); + return [ + 'id' => $object->getId(), + 'unique_id' => $object->getUuid(), + 'template' => $template?->getId() ? $this->templateNormalizer->normalize($template) : 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(), + 'format_options' => $object->getFormat()->getFormatOptions() + ], + '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'), + '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(), + 'user_selection' => $object->getOptions()->getUserSelection(), + ], + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Message; + } +} diff --git a/src/Serializer/SubscriberListNormalizer.php b/src/Serializer/SubscriberListNormalizer.php new file mode 100644 index 0000000..3bf93ec --- /dev/null +++ b/src/Serializer/SubscriberListNormalizer.php @@ -0,0 +1,40 @@ + $object->getId(), + 'name' => $object->getName(), + 'created_at' => $object->getCreatedAt()->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/Serializer/SubscriberNormalizer.php b/src/Serializer/SubscriberNormalizer.php index 99197a8..fb175b5 100644 --- a/src/Serializer/SubscriberNormalizer.php +++ b/src/Serializer/SubscriberNormalizer.php @@ -22,20 +22,21 @@ 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(), '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(), '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->getCreatedAt()->format('Y-m-d\TH:i:sP'), ]; }, $object->getSubscriptions()->toArray()), ]; diff --git a/src/Serializer/SubscriptionNormalizer.php b/src/Serializer/SubscriptionNormalizer.php new file mode 100644 index 0000000..58df4fb --- /dev/null +++ b/src/Serializer/SubscriptionNormalizer.php @@ -0,0 +1,46 @@ +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()), + 'subscription_date' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Subscription; + } +} 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..bdf29e4 --- /dev/null +++ b/src/Serializer/TemplateNormalizer.php @@ -0,0 +1,45 @@ + $object->getId(), + 'title' => $object->getTitle(), + 'content' => $object->getContent(), + 'text' => $object->getText(), + '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/src/Service/Builder/BuilderFromDtoInterface.php b/src/Service/Builder/BuilderFromDtoInterface.php new file mode 100644 index 0000000..2778d9a --- /dev/null +++ b/src/Service/Builder/BuilderFromDtoInterface.php @@ -0,0 +1,11 @@ +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 (isset($request->templateId)) { + $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/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/AdministratorManager.php b/src/Service/Manager/AdministratorManager.php new file mode 100644 index 0000000..a56a809 --- /dev/null +++ b/src/Service/Manager/AdministratorManager.php @@ -0,0 +1,63 @@ +entityManager = $entityManager; + $this->hashGenerator = $hashGenerator; + } + + public function createAdministrator(CreateAdministratorRequest $dto): Administrator + { + $administrator = new Administrator(); + $administrator->setLoginName($dto->loginName); + $administrator->setEmail($dto->email); + $administrator->setSuperUser($dto->superUser); + $hashedPassword = $this->hashGenerator->createPasswordHash($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->hashGenerator->createPasswordHash($dto->password); + $administrator->setPasswordHash($hashedPassword); + } + + $this->entityManager->flush(); + } + + public function deleteAdministrator(Administrator $administrator): void + { + $this->entityManager->remove($administrator); + $this->entityManager->flush(); + } +} diff --git a/src/Service/Manager/MessageManager.php b/src/Service/Manager/MessageManager.php new file mode 100644 index 0000000..96dc11d --- /dev/null +++ b/src/Service/Manager/MessageManager.php @@ -0,0 +1,57 @@ +messageRepository = $messageRepository; + $this->messageBuilder = $messageBuilder; + } + + public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $authUser): Message + { + $context = new MessageContext($authUser); + $message = $this->messageBuilder->buildFromRequest($createMessageRequest, $context); + $this->messageRepository->save($message); + + 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; + } + + 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/Manager/SessionManager.php b/src/Service/Manager/SessionManager.php new file mode 100644 index 0000000..f0d02cc --- /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); + } +} diff --git a/src/Service/Manager/SubscriberListManager.php b/src/Service/Manager/SubscriberListManager.php new file mode 100644 index 0000000..7e8346e --- /dev/null +++ b/src/Service/Manager/SubscriberListManager.php @@ -0,0 +1,55 @@ +subscriberListRepository = $subscriberListRepository; + } + + 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); + + $this->subscriberListRepository->save($subscriberList); + + return $subscriberList; + } + + /** + * @return SubscriberList[] + */ + public function getPaginated(PaginationCursorRequest $pagination): array + { + return $this->subscriberListRepository->getAfterId($pagination->afterId, $pagination->limit); + } + + public function getTotalCount(): int + { + return $this->subscriberListRepository->count(); + } + + public function delete(SubscriberList $subscriberList): void + { + $this->subscriberListRepository->remove($subscriberList); + } +} diff --git a/src/Service/Manager/SubscriberManager.php b/src/Service/Manager/SubscriberManager.php new file mode 100644 index 0000000..174dcdc --- /dev/null +++ b/src/Service/Manager/SubscriberManager.php @@ -0,0 +1,72 @@ +subscriberRepository = $subscriberRepository; + $this->entityManager = $entityManager; + } + + public function createSubscriber(CreateSubscriberRequest $subscriberRequest): Subscriber + { + $subscriber = new Subscriber(); + $subscriber->setEmail($subscriberRequest->email); + $confirmed = (bool)$subscriberRequest->requestConfirmation; + $subscriber->setConfirmed(!$confirmed); + $subscriber->setBlacklisted(false); + $subscriber->setHtmlEmail((bool)$subscriberRequest->htmlEmail); + $subscriber->setDisabled(false); + + $this->subscriberRepository->save($subscriber); + + 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; + } + + public function deleteSubscriber(Subscriber $subscriber): void + { + $this->subscriberRepository->remove($subscriber); + } +} diff --git a/src/Service/Manager/SubscriptionManager.php b/src/Service/Manager/SubscriptionManager.php new file mode 100644 index 0000000..ab410ea --- /dev/null +++ b/src/Service/Manager/SubscriptionManager.php @@ -0,0 +1,90 @@ +subscriptionRepository = $subscriptionRepository; + $this->subscriberRepository = $subscriberRepository; + } + + /** @return Subscription[] */ + public function createSubscriptions(SubscriberList $subscriberList, array $emails): array + { + $subscriptions = []; + foreach ($emails as $email) { + $subscriptions[] = $this->createSubscription($subscriberList, $email); + } + + return $subscriptions; + } + + 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) { + return $existingSubscription; + } + + $subscription = new Subscription(); + $subscription->setSubscriber($subscriber); + $subscription->setSubscriberList($subscriberList); + + $this->subscriptionRepository->save($subscription); + + return $subscription; + } + + public function deleteSubscriptions(SubscriberList $subscriberList, array $emails): void + { + 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); + } + + $this->subscriptionRepository->remove($subscription); + } + + /** @return Subscriber[] */ + public function getSubscriberListMembers(SubscriberList $list): array + { + return $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); + } +} diff --git a/src/Service/Manager/TemplateImageManager.php b/src/Service/Manager/TemplateImageManager.php new file mode 100644 index 0000000..3a594c2 --- /dev/null +++ b/src/Service/Manager/TemplateImageManager.php @@ -0,0 +1,104 @@ + '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/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/Validator/Constraint/ContainsPlaceholder.php b/src/Validator/Constraint/ContainsPlaceholder.php new file mode 100644 index 0000000..f6179ed --- /dev/null +++ b/src/Validator/Constraint/ContainsPlaceholder.php @@ -0,0 +1,14 @@ +placeholder)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ placeholder }}', $constraint->placeholder) + ->addViolation(); + } + } +} diff --git a/src/Validator/Constraint/EmailExists.php b/src/Validator/Constraint/EmailExists.php new file mode 100644 index 0000000..48d0045 --- /dev/null +++ b/src/Validator/Constraint/EmailExists.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Validator/Constraint/EmailExistsValidator.php b/src/Validator/Constraint/EmailExistsValidator.php new file mode 100644 index 0000000..9c4f5b5 --- /dev/null +++ b/src/Validator/Constraint/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 NotFoundHttpException('Subscriber with email does not exists.'); + } + } +} diff --git a/src/Validator/Constraint/TemplateExists.php b/src/Validator/Constraint/TemplateExists.php new file mode 100644 index 0000000..b46b395 --- /dev/null +++ b/src/Validator/Constraint/TemplateExists.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Validator/Constraint/TemplateExistsValidator.php b/src/Validator/Constraint/TemplateExistsValidator.php new file mode 100644 index 0000000..0cfdc08 --- /dev/null +++ b/src/Validator/Constraint/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.'); + } + } +} diff --git a/src/Validator/Constraint/UniqueEmail.php b/src/Validator/Constraint/UniqueEmail.php new file mode 100644 index 0000000..72152bc --- /dev/null +++ b/src/Validator/Constraint/UniqueEmail.php @@ -0,0 +1,25 @@ +entityClass = $entityClass; + } + + public function validatedBy(): string + { + return UniqueEmailValidator::class; + } +} diff --git a/src/Validator/Constraint/UniqueEmailValidator.php b/src/Validator/Constraint/UniqueEmailValidator.php new file mode 100644 index 0000000..906a526 --- /dev/null +++ b/src/Validator/Constraint/UniqueEmailValidator.php @@ -0,0 +1,45 @@ +entityManager + ->getRepository($constraint->entityClass) + ->findOneBy(['email' => $value]); + + $dto = $this->context->getObject(); + $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 new file mode 100644 index 0000000..ae5a01d --- /dev/null +++ b/src/Validator/RequestValidator.php @@ -0,0 +1,80 @@ +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, + null, + ['allow_extra_attributes' => true] + ); + } catch (Throwable $e) { + throw new BadRequestHttpException('Invalid request data: ' . $e->getMessage()); + } + + return $this->validateDto($dto); + } + + public function validateDto(RequestInterface $request): RequestInterface + { + $errors = $this->validator->validate($request); + + if (count($errors) > 0) { + $lines = []; + foreach ($errors as $violation) { + $lines[] = sprintf( + '%s: %s', + $violation->getPropertyPath(), + $violation->getMessage() + ); + } + + $message = implode("\n", $lines); + + throw new UnprocessableEntityHttpException($message); + } + + return $request; + } +} 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 @@ + 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 @@ +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']); } /** diff --git a/tests/Integration/Controller/CampaignControllerTest.php b/tests/Integration/Controller/CampaignControllerTest.php new file mode 100644 index 0000000..9949b7c --- /dev/null +++ b/tests/Integration/Controller/CampaignControllerTest.php @@ -0,0 +1,89 @@ +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['items'][0]); + self::assertArrayHasKey('message_content', $response['items'][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(); + } + + 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/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 91% rename from tests/Integration/Controller/Fixtures/AdministratorFixture.php rename to tests/Integration/Controller/Fixtures/Identity/AdministratorFixture.php index 09f30d6..282c79c 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; @@ -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/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 94% rename from tests/Integration/Controller/Fixtures/AdministratorTokenFixture.php rename to tests/Integration/Controller/Fixtures/Identity/AdministratorTokenFixture.php index d3d022b..b22998d 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; @@ -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/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..0986b4f --- /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']), + new DateTime($row['sendstart']), + ); + $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'], + $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, 'updatedAt', new DateTime($row['modified'])); + } while (true); + + fclose($handle); + } +} 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 88% rename from tests/Integration/Controller/Fixtures/SubscriberListFixture.php rename to tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php index 5ef4955..4d743f1 100644 --- a/tests/Integration/Controller/Fixtures/SubscriberListFixture.php +++ b/tests/Integration/Controller/Fixtures/Messaging/SubscriberListFixture.php @@ -2,13 +2,13 @@ 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; 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; @@ -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/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/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 89% rename from tests/Integration/Controller/Fixtures/SubscriberFixture.php rename to tests/Integration/Controller/Fixtures/Subscription/SubscriberFixture.php index 9a3b600..7992ea8 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; @@ -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.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 86% rename from tests/Integration/Controller/Fixtures/SubscriptionFixture.php rename to tests/Integration/Controller/Fixtures/Subscription/SubscriptionFixture.php index 88be840..a87eed3 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\Messaging\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; @@ -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 5574f9c..97dea15 100644 --- a/tests/Integration/Controller/ListControllerTest.php +++ b/tests/Integration/Controller/ListControllerTest.php @@ -4,13 +4,13 @@ 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; -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. @@ -60,40 +60,46 @@ public function testGetListsWithCurrentSessionKeyReturnsListData() $this->authenticatedJsonRequest('get', '/api/v2/lists'); - $this->assertJsonResponseContentEquals( - [ + $this->assertJsonResponseContentEquals([ + 'items' => [ [ + 'id' => 1, 'name' => 'News', + 'created_at' => '2016-06-22T15:01:17+00:00', '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, ], [ + 'id' => 2, 'name' => 'More news', + 'created_at' => '2016-06-22T15:01:17+00:00', 'description' => '', - 'creation_date' => '2016-06-22T15:01:17+00:00', 'list_position' => 12, 'subject_prefix' => '', 'public' => true, 'category' => '', - 'id' => 2, ], [ + 'id' => 3, 'name' => 'Tech news', + 'created_at' => '2019-02-11T15:01:15+00:00', 'description' => '', - 'creation_date' => '2019-02-11T15:01:15+00:00', 'list_position' => 12, 'subject_prefix' => '', 'public' => true, 'category' => '', - 'id' => 3, ], - ] - ); + ], + 'pagination' => [ + 'total' => 3, + 'limit' => 25, + 'has_more' => false, + 'next_cursor' => 3, + ], + ]); } public function testGetListWithoutSessionKeyForExistingListReturnsForbiddenStatus() @@ -129,14 +135,14 @@ public function testGetListWithCurrentSessionKeyReturnsListData() $this->assertJsonResponseContentEquals( [ + 'id' => 1, 'name' => 'News', + 'created_at' => '2016-06-22T15:01:17+00:00', '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, ] ); } @@ -226,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() @@ -237,110 +251,102 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr $this->assertJsonResponseContentEquals( [ - [ - 'id' => 1, - 'email' => 'oliver@example.com', - 'creation_date' => '2016-07-22T15:01:17+00:00', - 'confirmed' => true, - 'blacklisted' => true, - 'bounce_count' => 17, - 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e89', - 'html_email' => true, - 'disabled' => true, - 'subscribedLists' => [ - [ - 'id' => 2, - 'name' => 'More news', - 'description' => '', - 'creation_date' => '2016-06-22T15:01:17+00:00', - 'public' => true, - ], - ], - ], [ - 'id' => 2, - 'email' => 'oliver1@example.com', - 'creation_date' => '2016-07-22T15:01:17+00:00', - 'confirmed' => true, - 'blacklisted' => true, - 'bounce_count' => 17, - 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e87', - 'html_email' => true, - 'disabled' => true, - 'subscribedLists' => [ - [ - 'id' => 2, - 'name' => 'More news', - 'description' => '', - 'creation_date' => '2016-06-22T15:01:17+00:00', - 'public' => true, + '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)', - 'creation_date' => '2016-06-22T15:01:17+00:00', - 'public' => true, + ], [ + '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, + ], ] ); } - 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'); - $responseContent = $this->getResponseContentAsInt(); + $payload = [ + 'description' => 'Missing name field' + ]; - self::assertSame(0, $responseContent); + $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'); - $responseContent = $this->getResponseContentAsInt(); + self::getClient()->request('POST', '/api/v2/lists', [], [], [], json_encode([ 'name' => 'UnauthorizedList'])); - self::assertSame(2, $responseContent); + $this->assertHttpForbidden(); } } 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/SessionControllerTest.php b/tests/Integration/Controller/SessionControllerTest.php index 72f1d58..eb7c8a7 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. @@ -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)); @@ -202,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/Integration/Controller/SubscriberControllerTest.php b/tests/Integration/Controller/SubscriberControllerTest.php index 8c34c77..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. @@ -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', '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]], ]; } /** - * @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() @@ -129,9 +148,9 @@ public function testPostSubscribersWithValidSessionKeyAssignsProvidedSubscriberD $email = 'subscriber@example.com'; $jsonData = [ 'email' => $email, - 'confirmed' => true, + 'requestConfirmation' => true, 'blacklisted' => true, - 'html_email' => true, + 'htmlEmail' => true, 'disabled' => true, ]; diff --git a/tests/Integration/Controller/SubscriptionControllerTest.php b/tests/Integration/Controller/SubscriptionControllerTest.php new file mode 100644 index 0000000..f43d396 --- /dev/null +++ b/tests/Integration/Controller/SubscriptionControllerTest.php @@ -0,0 +1,64 @@ +get(SubscriptionController::class) + ); + } + + 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 testGetListSubscribersCountWithCurrentSessionKeyForExistingListReturnsOkayStatus() + { + $this->loadFixtures([SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('get', '/api/v2/lists/1/subscribers/count'); + + $this->assertHttpOkay(); + } +} diff --git a/tests/Integration/Controller/TemplateControllerTest.php b/tests/Integration/Controller/TemplateControllerTest.php new file mode 100644 index 0000000..af65097 --- /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['items'][0]); + self::assertArrayHasKey('title', $response['items'][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/Integration/EventListener/ExceptionListenerTest.php b/tests/Integration/EventListener/ExceptionListenerTest.php new file mode 100644 index 0000000..cc2a9f3 --- /dev/null +++ b/tests/Integration/EventListener/ExceptionListenerTest.php @@ -0,0 +1,78 @@ +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 651d221..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 = ['login_name' => $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/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/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/Serializer/MessageNormalizerTest.php b/tests/Unit/Serializer/MessageNormalizerTest.php new file mode 100644 index 0000000..12c6b06 --- /dev/null +++ b/tests/Unit/Serializer/MessageNormalizerTest.php @@ -0,0 +1,96 @@ +normalizer = new MessageNormalizer(new TemplateNormalizer(new TemplateImageNormalizer())); + } + + public function testSupportsNormalization(): void + { + $message = $this->createMock(Message::class); + $this->assertTrue($this->normalizer->supportsNormalization($message)); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $template = $this->createConfiguredMock(Template::class, [ + 'getId' => 5, + 'getTitle' => 'Test Template', + 'getContent' => 'Hello', + 'getText' => '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); + + $result = $this->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 + { + $this->assertSame([], $this->normalizer->normalize(new \stdClass())); + } +} diff --git a/tests/Unit/Serializer/SubscriberListNormalizerTest.php b/tests/Unit/Serializer/SubscriberListNormalizerTest.php new file mode 100644 index 0000000..aafde29 --- /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('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'); + $mock->method('isPublic')->willReturn(true); + $mock->method('getCategory')->willReturn('technology'); + + $normalizer = new SubscriberListNormalizer(); + $result = $normalizer->normalize($mock); + + $this->assertSame([ + 'id' => 101, + 'name' => 'Tech News', + 'created_at' => '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..b0d6168 --- /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('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('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('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); + $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', + 'created_at' => '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', + 'created_at' => '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..b7de91f --- /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('getCreatedAt')->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/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 new file mode 100644 index 0000000..ba2ead4 --- /dev/null +++ b/tests/Unit/Service/Builder/MessageBuilderTest.php @@ -0,0 +1,148 @@ +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/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/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/Manager/MessageManagerTest.php b/tests/Unit/Service/Manager/MessageManagerTest.php new file mode 100644 index 0000000..6b08731 --- /dev/null +++ b/tests/Unit/Service/Manager/MessageManagerTest.php @@ -0,0 +1,152 @@ +createMock(MessageRepository::class); + $messageBuilder = $this->createMock(MessageBuilder::class); + + $manager = new MessageManager($messageRepository, $messageBuilder); + + $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); + + $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($expectedMessage); + + $message = $manager->createMessage($request, $authUser); + + $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()); + } +} 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..20e2d77 --- /dev/null +++ b/tests/Unit/Service/Manager/SubscriberListManagerTest.php @@ -0,0 +1,77 @@ +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 testGetPaginated(): void + { + $list = new SubscriberList(); + $this->subscriberListRepository + ->expects($this->once()) + ->method('getAfterId') + ->willReturn([$list]); + + $result = $this->manager->getPaginated(new PaginationCursorRequest(0)); + + $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/Unit/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Service/Manager/SubscriberManagerTest.php new file mode 100644 index 0000000..a159d91 --- /dev/null +++ b/tests/Unit/Service/Manager/SubscriberManagerTest.php @@ -0,0 +1,47 @@ +createMock(SubscriberRepository::class); + $emMock = $this->createMock(EntityManagerInterface::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, $emMock); + + $dto = new CreateSubscriberRequest(); + $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/Unit/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Service/Manager/SubscriptionManagerTest.php new file mode 100644 index 0000000..729deaa --- /dev/null +++ b/tests/Unit/Service/Manager/SubscriptionManagerTest.php @@ -0,0 +1,107 @@ +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); + } + + 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/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/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); + } +} 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/EmailExistsValidatorTest.php b/tests/Unit/Validator/Constraint/EmailExistsValidatorTest.php new file mode 100644 index 0000000..c989b79 --- /dev/null +++ b/tests/Unit/Validator/Constraint/EmailExistsValidatorTest.php @@ -0,0 +1,86 @@ +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); + } + + 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/Unit/Validator/Constraint/TemplateExistsValidatorTest.php b/tests/Unit/Validator/Constraint/TemplateExistsValidatorTest.php new file mode 100644 index 0000000..04e478d --- /dev/null +++ b/tests/Unit/Validator/Constraint/TemplateExistsValidatorTest.php @@ -0,0 +1,86 @@ +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/Unit/Validator/Constraint/UniqueEmailValidatorTest.php b/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php new file mode 100644 index 0000000..4645c0e --- /dev/null +++ b/tests/Unit/Validator/Constraint/UniqueEmailValidatorTest.php @@ -0,0 +1,149 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->validator = new UniqueEmailValidator($this->entityManager); + $this->context = $this->createMock(ExecutionContextInterface::class); + $this->validator->initialize($this->context); + } + + public function testThrowsUnexpectedTypeExceptionWhenConstraintIsWrong(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate('anything', $this->createMock(Constraint::class)); + } + + public function testSkipsValidationForNullOrEmpty(): void + { + $this->entityManager->expects(self::never())->method('getRepository'); + + $this->validator->validate(null, new UniqueEmail(Subscriber::class)); + $this->validator->validate('', new UniqueEmail(Subscriber::class)); + + $this->addToAssertionCount(1); + } + + public function testThrowsUnexpectedValueExceptionForNonString(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(123, new UniqueEmail(Subscriber::class)); + } + + public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDifferentId(): void + { + $email = 'foo@bar.com'; + + $existingUser = $this->createConfiguredMock(Subscriber::class, [ + 'getId' => 99 + ]); + + $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; + }; + + $this->context + ->method('getObject') + ->willReturn($dto); + + $this->expectException(ConflictHttpException::class); + $this->expectExceptionMessage('Email already exists.'); + + $this->validator->validate($email, new UniqueEmail(Subscriber::class)); + } + + public function testAllowsSameEmailForSameSubscriberId(): void + { + $email = 'foo@bar.com'; + + $existingUser = $this->createConfiguredMock(Subscriber::class, [ + 'getId' => 100 + ]); + + $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; + }; + + $this->context + ->method('getObject') + ->willReturn($dto); + + $this->validator->validate($email, new UniqueEmail(Subscriber::class)); + + $this->addToAssertionCount(1); + } + + public function testAllowsUniqueEmailWhenNoExistingSubscriber(): void + { + $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; + }; + + $this->context + ->method('getObject') + ->willReturn($dto); + + $this->validator->validate('new@example.com', new UniqueEmail(Subscriber::class)); + + $this->addToAssertionCount(1); + } +} 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); + } +} diff --git a/tests/Unit/Validator/RequestValidatorTest.php b/tests/Unit/Validator/RequestValidatorTest.php new file mode 100644 index 0000000..d8ed2f3 --- /dev/null +++ b/tests/Unit/Validator/RequestValidatorTest.php @@ -0,0 +1,148 @@ +serializer = $this->createMock(DenormalizerInterface::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"}'; + $expectedData = ['foo' => 'bar']; + + $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([], [], [], [], [], [], $json); + + $result = $this->requestValidator->validate($request, DummyRequestDto::class); + self::assertSame($dto, $result); + } + + public function testValidateThrowsOnInvalidJson(): void + { + $json = '{ invalid json }'; + $request = new Request([], [], [], [], [], [], $json); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Invalid JSON'); + + $this->requestValidator->validate($request, DummyRequestDto::class); + } + + public function testValidateThrowsOnConstraintViolations(): void + { + $dto = $this->createMock(RequestInterface::class); + $json = '{"email":"bad"}'; + $request = new Request([], [], [], [], [], [], $json); + + $this->serializer + ->expects(self::once()) + ->method('denormalize') + ->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); + } + + 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); + } +} 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); + } +}