diff --git a/composer.json b/composer.json index 890aafb..3bc9442 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,17 @@ { "name": "Xheni Myrtaj", "email": "xheni@phplist.com", - "role": "Maintainer" + "role": "Former developer" }, { "name": "Oliver Klee", "email": "oliver@phplist.com", "role": "Former developer" + }, + { + "name": "Tatevik Grigoryan", + "email": "tatevik@phplist.com", + "role": "Maintainer" } ], "support": { @@ -31,7 +36,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "v5.0.0-alpha7", + "phplist/core": "dev-ISSUE-345", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", @@ -93,7 +98,6 @@ "symfony-tests-dir": "tests", "phplist/core": { "bundles": [ - "FOS\\RestBundle\\FOSRestBundle", "PhpList\\RestBundle\\PhpListRestBundle" ], "routes": { @@ -111,6 +115,11 @@ "resource": "@PhpListRestBundle/Messaging/Controller/", "type": "attribute", "prefix": "/api/v2" + }, + "rest-api-analitics": { + "resource": "@PhpListRestBundle/Statistics/Controller/", + "type": "attribute", + "prefix": "/api/v2" } } } diff --git a/config/services/controllers.yml b/config/services/controllers.yml index 857146f..9f7566e 100644 --- a/config/services/controllers.yml +++ b/config/services/controllers.yml @@ -24,3 +24,10 @@ services: autowire: true autoconfigure: true public: true + + PhpList\RestBundle\Statistics\Controller\: + resource: '../src/Statistics/Controller' + tags: [ 'controller.service_arguments' ] + autowire: true + autoconfigure: true + public: true diff --git a/config/services/managers.yml b/config/services/managers.yml index eeb4958..aa0da43 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -39,3 +39,16 @@ services: PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: autowire: true autoconfigure: true + + PhpList\Core\Domain\Analytics\Service\Manager\LinkTrackManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Analytics\Service\Manager\UserMessageViewManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Analytics\Service\AnalyticsService: + autowire: true + autoconfigure: true + diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 0420706..e27e1e4 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -69,3 +69,19 @@ services: PhpList\RestBundle\Subscription\Serializer\SubscribersExportRequestNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/config/services/providers.yml b/config/services/providers.yml index be69707..4f276e2 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -7,3 +7,7 @@ services: PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider: autowire: true autoconfigure: true + + PhpList\RestBundle\Messaging\Service\CampaignService: + autowire: true + autoconfigure: true diff --git a/src/Common/Controller/BaseController.php b/src/Common/Controller/BaseController.php index 32bed0a..136216b 100644 --- a/src/Common/Controller/BaseController.php +++ b/src/Common/Controller/BaseController.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +/** @SuppressWarnings(PHPMD.NumberOfChildren) */ abstract class BaseController extends AbstractController { protected Authentication $authentication; diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index f48bf86..6db218f 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -5,11 +5,14 @@ namespace PhpList\RestBundle\Common\EventListener; use Exception; +use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Validator\Exception\ValidatorException; class ExceptionListener { @@ -34,6 +37,21 @@ public function onKernelException(ExceptionEvent $event): void 'message' => $exception->getMessage(), ], $exception->getStatusCode()); $event->setResponse($response); + } elseif ($exception instanceof AdminAttributeCreationException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], $exception->getStatusCode()); + $event->setResponse($response); + } elseif ($exception instanceof ValidatorException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], 400); + $event->setResponse($response); + } elseif ($exception instanceof AccessDeniedException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], 403); + $event->setResponse($response); } elseif ($exception instanceof Exception) { $response = new JsonResponse([ 'message' => $exception->getMessage(), diff --git a/src/Identity/OpenApi/SwaggerSchemasRequest.php b/src/Identity/OpenApi/SwaggerSchemasRequest.php index bc1f096..d0421ec 100644 --- a/src/Identity/OpenApi/SwaggerSchemasRequest.php +++ b/src/Identity/OpenApi/SwaggerSchemasRequest.php @@ -36,6 +36,18 @@ type: 'boolean', example: false ), + new OA\Property( + property: 'privileges', + description: 'Array of privileges where keys are privilege names and values are booleans', + properties: [ + new OA\Property(property: 'subscribers', type: 'boolean', example: true), + new OA\Property(property: 'campaigns', type: 'boolean', example: false), + new OA\Property(property: 'statistics', type: 'boolean', example: true), + new OA\Property(property: 'settings', type: 'boolean', example: false), + ], + type: 'object', + example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + ), ], type: 'object' )] @@ -68,6 +80,18 @@ type: 'boolean', example: false ), + new OA\Property( + property: 'privileges', + description: 'Array of privileges where keys are privilege names and values are booleans', + properties: [ + new OA\Property(property: 'subscribers', type: 'boolean', example: true), + new OA\Property(property: 'campaigns', type: 'boolean', example: false), + new OA\Property(property: 'statistics', type: 'boolean', example: true), + new OA\Property(property: 'settings', type: 'boolean', example: false), + ], + type: 'object', + example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + ), ], type: 'object' )] diff --git a/src/Identity/Request/CreateAdministratorRequest.php b/src/Identity/Request/CreateAdministratorRequest.php index 921bdda..f69c70c 100644 --- a/src/Identity/Request/CreateAdministratorRequest.php +++ b/src/Identity/Request/CreateAdministratorRequest.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\RestBundle\Common\Request\RequestInterface; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmail; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueLoginName; @@ -31,13 +32,26 @@ class CreateAdministratorRequest implements RequestInterface #[Assert\Type('bool')] public bool $superUser = false; + /** + * Array of privileges where keys are privilege names (from PrivilegeFlag enum) and values are booleans. + * Example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + */ + #[Assert\Type('array')] + #[Assert\All([ + 'constraints' => [ + new Assert\Type(['type' => 'bool']), + ], + ])] + public array $privileges = []; + public function getDto(): CreateAdministratorDto { return new CreateAdministratorDto( - $this->loginName, - $this->password, - $this->email, - $this->superUser + loginName: $this->loginName, + password: $this->password, + email: $this->email, + isSuperUser: $this->superUser, + privileges: $this->privileges ); } } diff --git a/src/Identity/Request/UpdateAdministratorRequest.php b/src/Identity/Request/UpdateAdministratorRequest.php index ce3b8d3..de9d725 100644 --- a/src/Identity/Request/UpdateAdministratorRequest.php +++ b/src/Identity/Request/UpdateAdministratorRequest.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\UpdateAdministratorDto; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\RestBundle\Common\Request\RequestInterface; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmail; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueLoginName; @@ -29,6 +30,18 @@ class UpdateAdministratorRequest implements RequestInterface #[Assert\Type('bool')] public ?bool $superAdmin = null; + /** + * Array of privileges where keys are privilege names (from PrivilegeFlag enum) and values are booleans. + * Example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + */ + #[Assert\Type('array')] + #[Assert\All([ + 'constraints' => [ + new Assert\Type(['type' => 'bool']), + ], + ])] + public array $privileges = []; + public function getDto(): UpdateAdministratorDto { return new UpdateAdministratorDto( @@ -37,6 +50,7 @@ public function getDto(): UpdateAdministratorDto password: $this->password, email: $this->email, superAdmin: $this->superAdmin, + privileges: $this->privileges ); } } diff --git a/src/Identity/Serializer/AdministratorNormalizer.php b/src/Identity/Serializer/AdministratorNormalizer.php index 35f33ec..5382bf1 100644 --- a/src/Identity/Serializer/AdministratorNormalizer.php +++ b/src/Identity/Serializer/AdministratorNormalizer.php @@ -26,6 +26,7 @@ public function normalize($object, string $format = null, array $context = []): 'login_name' => $object->getLoginName(), 'email' => $object->getEmail(), 'super_admin' => $object->isSuperUser(), + 'privileges' => $object->getPrivileges()->all(), 'created_at' => $object->getCreatedAt()?->format(DateTimeInterface::ATOM), ]; } diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index d4e74f9..29c198b 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -5,16 +5,13 @@ namespace PhpList\RestBundle\Messaging\Controller; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Service\MessageManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; -use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Messaging\Request\CreateMessageRequest; use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest; -use PhpList\RestBundle\Messaging\Serializer\MessageNormalizer; +use PhpList\RestBundle\Messaging\Service\CampaignService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -29,21 +26,15 @@ #[Route('/campaigns', name: 'campaign_')] class CampaignController extends BaseController { - private MessageNormalizer $normalizer; - private MessageManager $messageManager; - private PaginatedDataProvider $paginatedProvider; + private CampaignService $campaignService; public function __construct( Authentication $authentication, RequestValidator $validator, - MessageNormalizer $normalizer, - MessageManager $messageManager, - PaginatedDataProvider $paginatedProvider, + CampaignService $campaignService, ) { parent::__construct($authentication, $validator); - $this->normalizer = $normalizer; - $this->messageManager = $messageManager; - $this->paginatedProvider = $paginatedProvider; + $this->campaignService = $campaignService; } #[Route('', name: 'get_list', methods: ['GET'])] @@ -103,12 +94,10 @@ public function __construct( )] public function getMessages(Request $request): JsonResponse { - $authUer = $this->requireAuthentication($request); - - $filter = (new MessageFilter())->setOwner($authUer); + $authUser = $this->requireAuthentication($request); return $this->json( - $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter), + $this->campaignService->getMessages($request, $authUser), Response::HTTP_OK ); } @@ -157,11 +146,7 @@ public function getMessage( ): JsonResponse { $this->requireAuthentication($request); - if (!$message) { - throw $this->createNotFoundException('Campaign not found.'); - } - - return $this->json($this->normalizer->normalize($message), Response::HTTP_OK); + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); } #[Route('', name: 'create', methods: ['POST'])] @@ -216,15 +201,17 @@ public function getMessage( ), ] )] - public function createMessage(Request $request, MessageNormalizer $normalizer): JsonResponse + public function createMessage(Request $request): JsonResponse { $authUser = $this->requireAuthentication($request); /** @var CreateMessageRequest $createMessageRequest */ $createMessageRequest = $this->validator->validate($request, CreateMessageRequest::class); - $data = $this->messageManager->createMessage($createMessageRequest->getDto(), $authUser); - return $this->json($normalizer->normalize($data), Response::HTTP_CREATED); + return $this->json( + $this->campaignService->createMessage($createMessageRequest, $authUser), + Response::HTTP_CREATED + ); } #[Route('/{messageId}', name: 'update', requirements: ['messageId' => '\d+'], methods: ['PUT'])] @@ -291,14 +278,13 @@ public function updateMessage( ): 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->getDto(), $message, $authUser); - return $this->json($this->normalizer->normalize($data), Response::HTTP_OK); + return $this->json( + $this->campaignService->updateMessage($updateMessageRequest, $authUser, $message), + Response::HTTP_OK + ); } #[Route('/{messageId}', name: 'delete', requirements: ['messageId' => '\d+'], methods: ['DELETE'])] @@ -348,13 +334,9 @@ public function deleteMessage( Request $request, #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null ): JsonResponse { - $this->requireAuthentication($request); - - if (!$message) { - throw $this->createNotFoundException('Campaign not found.'); - } + $authUser = $this->requireAuthentication($request); - $this->messageManager->delete($message); + $this->campaignService->deleteMessage($authUser, $message); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php new file mode 100644 index 0000000..b6680d1 --- /dev/null +++ b/src/Messaging/Service/CampaignService.php @@ -0,0 +1,90 @@ +setOwner($administrator); + + return $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter); + } + + public function getMessage(Message $message = null): array + { + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + + return $this->normalizer->normalize($message); + } + + public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $administrator): array + { + if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw new AccessDeniedHttpException('You are not allowed to create campaigns.'); + } + + $data = $this->messageManager->createMessage($createMessageRequest->getDto(), $administrator); + + return $this->normalizer->normalize($data); + } + + public function updateMessage( + UpdateMessageRequest $updateMessageRequest, + Administrator $administrator, + Message $message = null + ): array { + if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw new AccessDeniedHttpException('You are not allowed to update campaigns.'); + } + + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + + $data = $this->messageManager->updateMessage( + $updateMessageRequest->getDto(), + $message, + $administrator + ); + + return $this->normalizer->normalize($data); + } + + public function deleteMessage(Administrator $administrator, Message $message = null): void + { + if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw new AccessDeniedHttpException('You are not allowed to delete campaigns.'); + } + + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + + $this->messageManager->delete($message); + } +} diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php new file mode 100644 index 0000000..90fa4d3 --- /dev/null +++ b/src/Statistics/Controller/AnalyticsController.php @@ -0,0 +1,369 @@ +analyticsService = $analyticsService; + $this->campaignStatsNormalizer = $campaignStatsNormalizer; + $this->viewOpensStatsNormalizer = $viewOpensStatsNormalizer; + $this->topDomainsNormalizer = $topDomainsNormalizer; + $this->topLocalPartsNormalizer = $topLocalPartsNormalizer; + } + + #[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])] + #[OA\Get( + path: '/analytics/campaigns', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics overview for campaigns.', + summary: 'Gets campaign statistics.', + tags: ['analytics'], + 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: 'limit', + description: 'Maximum number of campaigns to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 20, minimum: 1) + ), + new OA\Parameter( + name: 'after_id', + description: 'Last seen campaign ID for pagination', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 0, minimum: 0) + ) + ], + 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/CampaignStatistics') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getCampaignStatistics(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', self::BATCH_SIZE); + $lastId = (int) $request->query->get('after_id', 0); + + $data = $this->analyticsService->getCampaignStatistics($limit, $lastId); + $normalizedData = $this->campaignStatsNormalizer->normalize($data, null, [ + 'limit' => $limit, + 'campaign_statistics' => true, + ]); + + return $this->json($normalizedData, Response::HTTP_OK); + } + + #[Route('/view-opens', name: 'view_opens_statistics', methods: ['GET'])] + #[OA\Get( + path: '/analytics/view-opens', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for view opens.', + summary: 'Gets view opens statistics.', + tags: ['analytics'], + 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: 'limit', + description: 'Maximum number of campaigns to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 20, minimum: 1) + ), + new OA\Parameter( + name: 'after_id', + description: 'Last seen campaign ID for pagination', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 0, minimum: 0) + ) + ], + 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/ViewOpensStatistics') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getViewOpensStatistics(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', self::BATCH_SIZE); + $lastId = (int) $request->query->get('after_id', 0); + + $data = $this->analyticsService->getViewOpensStatistics($limit, $lastId); + $normalizedData = $this->viewOpensStatsNormalizer->normalize($data, null, [ + 'view_opens_statistics' => true, + 'limit' => $limit + ]); + + return $this->json($normalizedData, Response::HTTP_OK); + } + + #[Route('/domains/top', name: 'top_domains', methods: ['GET'])] + #[OA\Get( + path: '/analytics/domains/top', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for the top domains with more than 5 subscribers.', + summary: 'Gets top domains statistics.', + tags: ['analytics'], + 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: 'limit', + description: 'Maximum number of domains to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 20, minimum: 1) + ), + new OA\Parameter( + name: 'min_subscribers', + description: 'Minimum number of subscribers per domain', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 5, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/TopDomainStats') + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getTopDomains(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', self::BATCH_SIZE); + $minSubscribers = (int) $request->query->get('min_subscribers', 5); + + $data = $this->analyticsService->getTopDomains($limit, $minSubscribers); + $normalizedData = $this->topDomainsNormalizer->normalize($data, null, [ + 'top_domains' => true, + ]); + + return $this->json($normalizedData, Response::HTTP_OK); + } + + #[Route('/domains/confirmation', name: 'domain_confirmation_statistics', methods: ['GET'])] + #[OA\Get( + path: '/analytics/domains/confirmation', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for domains showing confirmation status.', + summary: 'Gets domain confirmation statistics.', + tags: ['analytics'], + 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: 'limit', + description: 'Maximum number of domains to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/DetailedDomainStats') + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getDomainConfirmationStatistics(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', 50); + + $data = $this->analyticsService->getDomainConfirmationStatistics($limit); + + return $this->json($data, Response::HTTP_OK); + } + + #[Route('/local-parts/top', name: 'top_local_parts', methods: ['GET'])] + #[OA\Get( + path: '/analytics/local-parts/top', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for the top local-parts of email addresses.', + summary: 'Gets top local-parts statistics.', + tags: ['analytics'], + 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: 'limit', + description: 'Maximum number of local-parts to return', + 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(ref: '#/components/schemas/LocalPartsStats') + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getTopLocalParts(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', 25); + + $data = $this->analyticsService->getTopLocalParts($limit); + $normalizedData = $this->topLocalPartsNormalizer->normalize($data, null, [ + 'top_local_parts' => true, + ]); + + return $this->json($normalizedData, Response::HTTP_OK); + } +} diff --git a/src/Statistics/OpenApi/SwaggerSchemasRequest.php b/src/Statistics/OpenApi/SwaggerSchemasRequest.php new file mode 100644 index 0000000..1dcb53a --- /dev/null +++ b/src/Statistics/OpenApi/SwaggerSchemasRequest.php @@ -0,0 +1,9 @@ +normalizeCampaign($item); + } + return [ + 'items' => $items, + 'pagination' => $this->normalizePagination($object, $context), + ]; + } + + private function normalizeCampaign(array $campaign): array + { + return [ + 'campaign_id' => $campaign['campaignId'] ?? 0, + 'subject' => $campaign['subject'] ?? '', + 'sent' => $campaign['sent'] ?? 0, + 'bounces' => $campaign['bounces'] ?? 0, + 'forwards' => $campaign['forwards'] ?? 0, + 'unique_views' => $campaign['uniqueViews'] ?? 0, + 'total_clicks' => $campaign['totalClicks'] ?? 0, + 'unique_clicks' => $campaign['uniqueClicks'] ?? 0, + 'date_sent' => $campaign['dateSent'] ?? null, + ]; + } + + private function normalizePagination(array $object, array $context): array + { + return [ + 'total' => $object['total'] ?? 0, + 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, + 'has_more' => $object['hasMore'] ?? false, + 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($data['campaign_statistics']); + } +} diff --git a/src/Statistics/Serializer/TopDomainsNormalizer.php b/src/Statistics/Serializer/TopDomainsNormalizer.php new file mode 100644 index 0000000..62c4d05 --- /dev/null +++ b/src/Statistics/Serializer/TopDomainsNormalizer.php @@ -0,0 +1,41 @@ + $domain['domain'] ?? '', + 'subscribers' => $domain['subscribers'] ?? 0, + ]; + } + + return [ + 'domains' => $domains, + 'total' => $object['total'] ?? 0, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($context['top_domains']); + } +} diff --git a/src/Statistics/Serializer/TopLocalPartsNormalizer.php b/src/Statistics/Serializer/TopLocalPartsNormalizer.php new file mode 100644 index 0000000..34ea851 --- /dev/null +++ b/src/Statistics/Serializer/TopLocalPartsNormalizer.php @@ -0,0 +1,42 @@ + $localPart['localPart'] ?? '', + 'count' => $localPart['count'] ?? 0, + 'percentage' => $localPart['percentage'] ?? 0.0, + ]; + } + + return [ + 'local_parts' => $localParts, + 'total' => $object['total'] ?? 0, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($context['top_local_parts']); + } +} diff --git a/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php new file mode 100644 index 0000000..85cf958 --- /dev/null +++ b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php @@ -0,0 +1,60 @@ +normalizeCampaign($item); + } + + return [ + 'items' => $items, + 'pagination' => $this->normalizePagination($object, $context), + ]; + } + + private function normalizeCampaign(array $item): array + { + return [ + 'campaign_id' => $item['campaignId'] ?? 0, + 'subject' => $item['subject'] ?? '', + 'sent' => $item['sent'] ?? 0, + 'unique_views' => $item['uniqueViews'] ?? 0, + 'rate' => $item['rate'] ?? 0.0, + ]; + } + + private function normalizePagination(array $object, array $context): array + { + return [ + 'total' => $object['total'] ?? 0, + 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, + 'has_more' => $object['hasMore'] ?? false, + 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($data['items']) && isset($context['view_opens_statistics']); + } +} diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index bad2924..717f80b 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Subscription\Controller; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Security\Authentication; @@ -89,7 +90,10 @@ public function __construct( )] public function createSubscriber(Request $request): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to create subscribers.'); + } /** @var CreateSubscriberRequest $subscriberRequest */ $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); @@ -156,7 +160,10 @@ public function updateSubscriber( Request $request, #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, ): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to update subscribers.'); + } if (!$subscriber) { throw $this->createNotFoundException('Subscriber not found.'); @@ -262,7 +269,10 @@ public function deleteSubscriber( Request $request, #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, ): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to delete subscribers.'); + } if (!$subscriber) { throw $this->createNotFoundException('Subscriber not found.'); diff --git a/src/Subscription/Controller/SubscriberImportController.php b/src/Subscription/Controller/SubscriberImportController.php index dcc60d7..40f31d7 100644 --- a/src/Subscription/Controller/SubscriberImportController.php +++ b/src/Subscription/Controller/SubscriberImportController.php @@ -6,6 +6,7 @@ use Exception; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter; use PhpList\Core\Security\Authentication; @@ -106,7 +107,10 @@ public function __construct( )] public function importSubscribers(Request $request): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to create subscribers.'); + } /** @var UploadedFile|null $file */ $file = $request->files->get('file'); diff --git a/tests/Helpers/DummyAnalyticsController.php b/tests/Helpers/DummyAnalyticsController.php new file mode 100644 index 0000000..fc3d46e --- /dev/null +++ b/tests/Helpers/DummyAnalyticsController.php @@ -0,0 +1,16 @@ +authenticatedJsonRequest('post', '/api/v2/administrators/attributes', [], [], [], json_encode([ 'name' => 'Test Attribute', - 'type' => 'text', + 'type' => 'textarea', 'order' => 1, 'defaultValue' => 'default', 'required' => true, @@ -41,7 +41,7 @@ public function testCreateAttributeDefinitionWithValidDataReturnsCreated(): void $this->assertHttpCreated(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('Test Attribute', $data['name']); - self::assertSame('text', $data['type']); + self::assertSame('textarea', $data['type']); self::assertSame(1, $data['list_order']); self::assertSame('default', $data['default_value']); self::assertTrue($data['required']); diff --git a/tests/Integration/Identity/Controller/AdministratorControllerTest.php b/tests/Integration/Identity/Controller/AdministratorControllerTest.php index b9fa249..e229917 100644 --- a/tests/Integration/Identity/Controller/AdministratorControllerTest.php +++ b/tests/Integration/Identity/Controller/AdministratorControllerTest.php @@ -60,11 +60,24 @@ public function testCreateAdministratorWithValidDataReturnsCreated(): void 'loginName' => 'new.admin', 'password' => 'NewPassword123!', 'email' => 'new.admin@example.com', + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], ])); $this->assertHttpCreated(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('new.admin', $data['login_name']); + + $administrator = $this->administratorRepository->findOneBy(['loginName' => 'new.admin']); + $privileges = $administrator->getPrivileges()->all(); + self::assertTrue($privileges['subscribers']); + self::assertFalse($privileges['campaigns']); + self::assertTrue($privileges['statistics']); + self::assertFalse($privileges['settings']); } public function testUpdateAdministratorReturnsOk(): void @@ -73,11 +86,24 @@ public function testUpdateAdministratorReturnsOk(): void $this->authenticatedJsonRequest('put', '/api/v2/administrators/1', [], [], [], json_encode([ 'email' => 'updated@example.com', + 'privileges' => [ + 'subscribers' => false, + 'campaigns' => true, + 'statistics' => false, + 'settings' => true, + ], ])); $this->assertHttpOkay(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('updated@example.com', $data['email']); + + $administrator = $this->administratorRepository->find(1); + $privileges = $administrator->getPrivileges()->all(); + self::assertFalse($privileges['subscribers']); + self::assertTrue($privileges['campaigns']); + self::assertFalse($privileges['statistics']); + self::assertTrue($privileges['settings']); } public function testDeleteAdministratorReturnsNoContent(): void @@ -116,4 +142,32 @@ public function testPutAdministratorWithInvalidIdReturns404(): void $this->assertHttpNotFound(); } + + public function testUpdateAdministratorPrivilegesOnly(): void + { + $this->loadFixtures([AdministratorFixture::class]); + + $originalAdmin = $this->administratorRepository->find(1); + $originalEmail = $originalAdmin->getEmail(); + + $this->authenticatedJsonRequest('put', '/api/v2/administrators/1', [], [], [], json_encode([ + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => true, + 'statistics' => true, + 'settings' => true, + ], + ])); + + $this->assertHttpOkay(); + + $updatedAdmin = $this->administratorRepository->find(1); + self::assertSame($originalEmail, $updatedAdmin->getEmail()); + + $privileges = $updatedAdmin->getPrivileges()->all(); + self::assertTrue($privileges['subscribers']); + self::assertTrue($privileges['campaigns']); + self::assertTrue($privileges['statistics']); + self::assertTrue($privileges['settings']); + } } diff --git a/tests/Integration/Identity/Fixtures/Administrator.csv b/tests/Integration/Identity/Fixtures/Administrator.csv index 3232667..9ae9fc8 100644 --- a/tests/Integration/Identity/Fixtures/Administrator.csv +++ b/tests/Integration/Identity/Fixtures/Administrator.csv @@ -1,3 +1,3 @@ -id,loginname,email,created,modified,password,passwordchanged,disabled,superuser -1,"john.doe","john@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3c","2017-06-28",0,1 -2,"jane.doe","jane@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3d","2017-06-28",0,1 +id,loginname,email,created,modified,password,passwordchanged,disabled,superuser,privileges +1,"john.doe","john@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3c","2017-06-28",0,1,a:4:{s:11:"subscribers";b:1;s:9:"campaigns";b:1;s:10:"statistics";b:1;s:8:"settings";b:1;} +2,"jane.doe","jane@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3d","2017-06-28",0,1, diff --git a/tests/Integration/Identity/Fixtures/AdministratorFixture.php b/tests/Integration/Identity/Fixtures/AdministratorFixture.php index 16be665..aa8790f 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorFixture.php @@ -43,6 +43,10 @@ public function load(ObjectManager $manager): void $admin->setPasswordHash($row['password']); $admin->setDisabled((bool) $row['disabled']); $admin->setSuperUser((bool) $row['superuser']); + $privileges = unserialize($row['privileges']); + if ($privileges) { + $admin->setPrivilegesFromArray(unserialize($row['privileges'])); + } $manager->persist($admin); diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php new file mode 100644 index 0000000..6e23329 --- /dev/null +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -0,0 +1,257 @@ +get(AnalyticsController::class)); + } + + public function testGetCampaignStatisticsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/campaigns'); + $this->assertHttpForbidden(); + } + + public function testGetCampaignStatisticsWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/analytics/campaigns', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testGetCampaignStatisticsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/campaigns'); + $this->assertHttpOkay(); + } + + public function testGetCampaignStatisticsReturnsCampaignData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/campaigns'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('items', $response); + self::assertArrayHasKey('pagination', $response); + } + + public function testGetViewOpensStatisticsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/view-opens'); + $this->assertHttpForbidden(); + } + + public function testGetViewOpensStatisticsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/view-opens'); + $this->assertHttpOkay(); + } + + public function testGetViewOpensStatisticsReturnsViewData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/view-opens'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('items', $response); + self::assertArrayHasKey('pagination', $response); + self::assertIsArray($response['items']); + self::assertIsArray($response['pagination']); + } + + public function testGetTopDomainsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/domains/top'); + $this->assertHttpForbidden(); + } + + public function testGetTopDomainsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top'); + $this->assertHttpOkay(); + } + + public function testGetTopDomainsReturnsDomainsData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertArrayHasKey('total', $response); + self::assertIsArray($response['domains']); + self::assertIsInt($response['total']); + } + + public function testGetTopDomainsWithLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?limit=5'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); + self::assertLessThanOrEqual(5, count($response['domains'])); + } + + public function testGetTopDomainsWithMinSubscribersParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?min_subscribers=10'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); + + // Verify all domains have at least 10 subscribers + foreach ($response['domains'] as $domain) { + self::assertArrayHasKey('subscribers', $domain); + self::assertGreaterThanOrEqual(10, $domain['subscribers']); + } + } + + public function testGetTopDomainsWithBothParameters(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?limit=3&min_subscribers=10'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); + self::assertLessThanOrEqual(3, count($response['domains'])); + + foreach ($response['domains'] as $domain) { + self::assertArrayHasKey('subscribers', $domain); + self::assertGreaterThanOrEqual(10, $domain['subscribers']); + } + } + + public function testGetTopDomainsWithInvalidLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?limit=invalid'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); + } + + public function testGetDomainConfirmationStatisticsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/domains/confirmation'); + $this->assertHttpForbidden(); + } + + public function testGetDomainConfirmationStatisticsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/confirmation'); + $this->assertHttpOkay(); + } + + public function testGetDomainConfirmationStatisticsReturnsConfirmationData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/confirmation'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertArrayHasKey('total', $response); + } + + public function testGetTopLocalPartsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/local-parts/top'); + $this->assertHttpForbidden(); + } + + public function testGetTopLocalPartsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top'); + $this->assertHttpOkay(); + } + + public function testGetTopLocalPartsReturnsLocalPartsData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('local_parts', $response); + self::assertArrayHasKey('total', $response); + self::assertIsArray($response['local_parts']); + self::assertIsInt($response['total']); + } + + public function testGetTopLocalPartsWithLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top?limit=5'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('local_parts', $response); + self::assertIsArray($response['local_parts']); + self::assertLessThanOrEqual(5, count($response['local_parts'])); + } + + public function testGetTopLocalPartsWithInvalidLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top?limit=invalid'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('local_parts', $response); + self::assertIsArray($response['local_parts']); + } +} diff --git a/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php b/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php index 6d43406..fe54dda 100644 --- a/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php +++ b/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php @@ -16,6 +16,12 @@ public function testGetDtoReturnsCorrectDto(): void $request->password = 'password123'; $request->email = 'test@example.com'; $request->superUser = true; + $request->privileges = [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ]; $dto = $request->getDto(); @@ -23,6 +29,12 @@ public function testGetDtoReturnsCorrectDto(): void $this->assertEquals('password123', $dto->password); $this->assertEquals('test@example.com', $dto->email); $this->assertTrue($dto->isSuperUser); + $this->assertEquals([ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], $dto->privileges); } public function testGetDtoWithDefaultSuperUserValue(): void @@ -38,5 +50,6 @@ public function testGetDtoWithDefaultSuperUserValue(): void $this->assertEquals('password123', $dto->password); $this->assertEquals('test@example.com', $dto->email); $this->assertFalse($dto->isSuperUser); + $this->assertEquals([], $dto->privileges); } } diff --git a/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php b/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php index 5bdbc1d..3eb5228 100644 --- a/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php +++ b/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php @@ -18,6 +18,12 @@ public function testGetDtoReturnsCorrectDto(): void $request->password = 'password123'; $request->email = 'test@example.com'; $request->superAdmin = true; + $request->privileges = [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ]; $dto = $request->getDto(); @@ -26,6 +32,12 @@ public function testGetDtoReturnsCorrectDto(): void $this->assertEquals('password123', $dto->password); $this->assertEquals('test@example.com', $dto->email); $this->assertTrue($dto->superAdmin); + $this->assertEquals([ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], $dto->privileges); } public function testGetDtoWithNullValues(): void @@ -40,5 +52,6 @@ public function testGetDtoWithNullValues(): void $this->assertNull($dto->password); $this->assertNull($dto->email); $this->assertNull($dto->superAdmin); + $this->assertEquals([], $dto->privileges); } } diff --git a/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php b/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php index 0fa075a..512fa5c 100644 --- a/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php +++ b/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php @@ -7,6 +7,7 @@ use DateTime; use InvalidArgumentException; use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\Privileges; use PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer; use PHPUnit\Framework\TestCase; @@ -20,6 +21,12 @@ public function testNormalizeValidAdministrator(): void $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')); + $admin->method('getPrivileges')->willReturn(new Privileges([ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ])); $normalizer = new AdministratorNormalizer(); $data = $normalizer->normalize($admin); @@ -30,6 +37,12 @@ public function testNormalizeValidAdministrator(): void 'login_name' => 'admin', 'email' => 'admin@example.com', 'super_admin' => true, + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], 'created_at' => '2024-01-01T10:00:00+00:00', ], $data); } diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php new file mode 100644 index 0000000..5293f0f --- /dev/null +++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php @@ -0,0 +1,297 @@ +messageManager = $this->createMock(MessageManager::class); + $this->paginatedProvider = $this->createMock(PaginatedDataProvider::class); + $this->normalizer = $this->createMock(MessageNormalizer::class); + + $this->campaignService = new CampaignService( + $this->messageManager, + $this->paginatedProvider, + $this->normalizer + ); + } + + public function testGetMessagesReturnsExpectedResult(): void + { + $request = new Request(); + $administrator = $this->createMock(Administrator::class); + $expectedResult = ['items' => [], 'pagination' => []]; + + $this->paginatedProvider->expects($this->once()) + ->method('getPaginatedList') + ->with( + $this->identicalTo($request), + $this->identicalTo($this->normalizer), + Message::class, + $this->callback(function (MessageFilter $filter) use ($administrator) { + return $filter->getOwner() === $administrator; + }) + ) + ->willReturn($expectedResult); + + $result = $this->campaignService->getMessages($request, $administrator); + + $this->assertSame($expectedResult, $result); + } + + public function testGetMessageThrowsExceptionWhenMessageIsNull(): void + { + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Campaign not found.'); + + $this->campaignService->getMessage(null); + } + + public function testGetMessageReturnsNormalizedMessage(): void + { + $message = $this->createMock(Message::class); + $expectedResult = ['id' => 1, 'subject' => 'Test Campaign']; + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($this->identicalTo($message)) + ->willReturn($expectedResult); + + $result = $this->campaignService->getMessage($message); + + $this->assertSame($expectedResult, $result); + } + + public function testCreateMessageThrowsExceptionWhenAdministratorLacksPrivileges(): void + { + $createMessageRequest = $this->createMock(CreateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(false); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to create campaigns.'); + + $this->campaignService->createMessage($createMessageRequest, $administrator); + } + + public function testCreateMessageReturnsNormalizedMessage(): void + { + $messageDto = $this->createMock(CreateMessageDto::class); + $createMessageRequest = $this->createMock(CreateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + $expectedResult = ['id' => 1, 'subject' => 'Test Campaign']; + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $createMessageRequest->expects($this->once()) + ->method('getDto') + ->willReturn($messageDto); + + $this->messageManager->expects($this->once()) + ->method('createMessage') + ->with($this->identicalTo($messageDto), $this->identicalTo($administrator)) + ->willReturn($message); + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($this->identicalTo($message)) + ->willReturn($expectedResult); + + $result = $this->campaignService->createMessage($createMessageRequest, $administrator); + + $this->assertSame($expectedResult, $result); + } + + public function testUpdateMessageThrowsExceptionWhenAdministratorLacksPrivileges(): void + { + $updateMessageRequest = $this->createMock(UpdateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(false); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to update campaigns.'); + + $this->campaignService->updateMessage($updateMessageRequest, $administrator, $message); + } + + public function testUpdateMessageThrowsExceptionWhenMessageIsNull(): void + { + $updateMessageRequest = $this->createMock(UpdateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Campaign not found.'); + + $this->campaignService->updateMessage($updateMessageRequest, $administrator, null); + } + + public function testUpdateMessageReturnsNormalizedMessage(): void + { + $messageDto = $this->createMock(UpdateMessageDto::class); + $updateMessageRequest = $this->createMock(UpdateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + $updatedMessage = $this->createMock(Message::class); + $expectedResult = ['id' => 1, 'subject' => 'Updated Campaign']; + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $updateMessageRequest->expects($this->once()) + ->method('getDto') + ->willReturn($messageDto); + + $this->messageManager->expects($this->once()) + ->method('updateMessage') + ->with( + $this->identicalTo($messageDto), + $this->identicalTo($message), + $this->identicalTo($administrator) + ) + ->willReturn($updatedMessage); + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($this->identicalTo($updatedMessage)) + ->willReturn($expectedResult); + + $result = $this->campaignService->updateMessage($updateMessageRequest, $administrator, $message); + + $this->assertSame($expectedResult, $result); + } + + public function testDeleteMessageThrowsExceptionWhenAdministratorLacksPrivileges(): void + { + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(false); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to delete campaigns.'); + + $this->campaignService->deleteMessage($administrator, $message); + } + + public function testDeleteMessageThrowsExceptionWhenMessageIsNull(): void + { + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Campaign not found.'); + + $this->campaignService->deleteMessage($administrator, null); + } + + public function testDeleteMessageCallsMessageManagerDelete(): void + { + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $this->messageManager->expects($this->once()) + ->method('delete') + ->with($this->identicalTo($message)); + + $this->campaignService->deleteMessage($administrator, $message); + } +} diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php new file mode 100644 index 0000000..452ff13 --- /dev/null +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -0,0 +1,445 @@ +authentication = $this->createMock(Authentication::class); + $validator = $this->createMock(RequestValidator::class); + $this->analyticsService = $this->createMock(AnalyticsService::class); + $campaignStatisticsNormalizer = new CampaignStatisticsNormalizer(); + $viewOpensStatisticsNormalizer = new ViewOpensStatisticsNormalizer(); + $topDomainsNormalizer = new TopDomainsNormalizer(); + $this->controller = new DummyAnalyticsController( + $this->authentication, + $validator, + $this->analyticsService, + $campaignStatisticsNormalizer, + $viewOpensStatisticsNormalizer, + $topDomainsNormalizer, + new TopLocalPartsNormalizer() + ); + + $this->privileges = $this->createMock(Privileges::class); + $this->administrator = $this->createMock(Administrator::class); + $this->administrator->method('getPrivileges')->willReturn($this->privileges); + } + + public function testGetCampaignStatisticsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getCampaignStatistics($request); + } + + public function testGetCampaignStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + $request->query->set('after_id', '10'); + + $serviceData = [ + 'campaigns' => [ + [ + 'campaignId' => 1, + 'subject' => 'Test Campaign', + 'dateSent' => '2023-01-01T00:00:00+00:00', + 'sent' => 100, + 'bounces' => 5, + 'forwards' => 2, + 'uniqueViews' => 80, + 'totalClicks' => 150, + 'uniqueClicks' => 70, + ] + ], + 'total' => 1, + 'hasMore' => false, + 'lastId' => 1, + ]; + + $normalizedData = [ + 'items' => [ + [ + 'campaign_id' => 1, + 'subject' => 'Test Campaign', + 'date_sent' => '2023-01-01T00:00:00+00:00', + 'sent' => 100, + 'bounces' => 5, + 'forwards' => 2, + 'unique_views' => 80, + 'total_clicks' => 150, + 'unique_clicks' => 70, + ] + ], + 'pagination' => [ + 'total' => 1, + 'limit' => 20, + 'has_more' => false, + 'next_cursor' => 2, + ], + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getCampaignStatistics') + ->with(20, 10) + ->willReturn($serviceData); + + $response = $this->controller->getCampaignStatistics($request); + + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($normalizedData, json_decode($response->getContent(), true)); + } + + public function testGetViewOpensStatisticsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getViewOpensStatistics($request); + } + + public function testGetViewOpensStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + $request->query->set('after_id', '10'); + + $expectedData = [ + 'campaigns' => [ + [ + 'campaignId' => 1, + 'subject' => 'Test Campaign', + 'sent' => 100, + 'uniqueViews' => 80, + 'rate' => 80.0, + ] + ], + 'total' => 1, + 'hasMore' => false, + 'lastId' => 1, + ]; + + $normalizedData = [ + 'items' => [ + [ + 'campaign_id' => 1, + 'subject' => 'Test Campaign', + 'sent' => 100, + 'unique_views' => 80, + 'rate' => 80, + ] + ], + 'pagination' => [ + 'total' => 1, + 'limit' => 20, + 'has_more' => false, + 'next_cursor' => 2, + ], + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getViewOpensStatistics') + ->with(20, 10) + ->willReturn($expectedData); + + $response = $this->controller->getViewOpensStatistics($request); + + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($normalizedData, json_decode($response->getContent(), true)); + } + + public function testGetTopDomainsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getTopDomains($request); + } + + public function testGetTopDomainsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + $request->query->set('min_subscribers', '10'); + + $expectedData = [ + 'domains' => [ + [ + 'domain' => 'example.com', + 'subscribers' => 50, + ] + ], + 'total' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getTopDomains') + ->with(20, 10) + ->willReturn($expectedData); + + $response = $this->controller->getTopDomains($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } + + public function testGetDomainConfirmationStatisticsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getDomainConfirmationStatistics($request); + } + + public function testGetDomainConfirmationStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + + $expectedData = [ + 'domains' => [ + [ + 'domain' => 'example.com', + 'confirmed' => [ + 'count' => 40, + 'percentage' => 80.0, + ], + 'unconfirmed' => [ + 'count' => 5, + 'percentage' => 10.0, + ], + 'blacklisted' => [ + 'count' => 5, + 'percentage' => 10.0, + ], + 'total' => [ + 'count' => 50, + 'percentage' => 100.0, + ], + ] + ], + 'total' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getDomainConfirmationStatistics') + ->with(20) + ->willReturn($expectedData); + + $response = $this->controller->getDomainConfirmationStatistics($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } + + public function testGetTopLocalPartsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getTopLocalParts($request); + } + + public function testGetTopLocalPartsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + + $expectedData = [ + 'localParts' => [ + [ + 'localPart' => 'info', + 'count' => 30, + 'percentage' => 60.0, + ] + ], + 'total' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getTopLocalParts') + ->with(20) + ->willReturn($expectedData); + + $response = $this->controller->getTopLocalParts($request); + + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals([ + 'local_parts' => [ + [ + 'local_part' => 'info', + 'count' => 30, + 'percentage' => 60.0, + ] + ], + 'total' => 1, + ], json_decode($response->getContent(), true)); + } +} diff --git a/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php b/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php new file mode 100644 index 0000000..486e27b --- /dev/null +++ b/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php @@ -0,0 +1,113 @@ + [ + ['domain' => 'example.com', 'subscribers' => 100], + ['domain' => 'test.org', 'subscribers' => 50], + ], + 'total' => 150, + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(150, $result['total']); + $this->assertCount(2, $result['domains']); + $this->assertEquals('example.com', $result['domains'][0]['domain']); + $this->assertEquals(100, $result['domains'][0]['subscribers']); + $this->assertEquals('test.org', $result['domains'][1]['domain']); + $this->assertEquals(50, $result['domains'][1]['subscribers']); + } + + public function testNormalizeWithMissingFields(): void + { + $data = [ + 'domains' => [ + ['domain' => 'example.com'], + ['subscribers' => 50], + [], + ], + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertCount(3, $result['domains']); + $this->assertEquals('example.com', $result['domains'][0]['domain']); + $this->assertEquals(0, $result['domains'][0]['subscribers']); + $this->assertEquals('', $result['domains'][1]['domain']); + $this->assertEquals(50, $result['domains'][1]['subscribers']); + $this->assertEquals('', $result['domains'][2]['domain']); + $this->assertEquals(0, $result['domains'][2]['subscribers']); + } + + public function testNormalizeWithEmptyDomains(): void + { + $data = [ + 'domains' => [], + 'total' => 0, + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertEmpty($result['domains']); + } + + public function testNormalizeWithNoDomains(): void + { + $data = [ + 'total' => 100, + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(100, $result['total']); + $this->assertEmpty($result['domains']); + } + + public function testNormalizeWithInvalidObject(): void + { + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize('not an array'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testSupportsNormalization(): void + { + $normalizer = new TopDomainsNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization([], null, ['top_domains' => true])); + $this->assertFalse($normalizer->supportsNormalization([], null, [])); + $this->assertFalse($normalizer->supportsNormalization('not an array', null, ['top_domains' => true])); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), null, ['top_domains' => true])); + } +} diff --git a/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php b/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php new file mode 100644 index 0000000..a08f80f --- /dev/null +++ b/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php @@ -0,0 +1,122 @@ + [ + ['localPart' => 'john', 'count' => 100, 'percentage' => 40.0], + ['localPart' => 'info', 'count' => 50, 'percentage' => 20.0], + ], + 'total' => 250, + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(250, $result['total']); + $this->assertCount(2, $result['local_parts']); + $this->assertEquals('john', $result['local_parts'][0]['local_part']); + $this->assertEquals(100, $result['local_parts'][0]['count']); + $this->assertEquals(40.0, $result['local_parts'][0]['percentage']); + $this->assertEquals('info', $result['local_parts'][1]['local_part']); + $this->assertEquals(50, $result['local_parts'][1]['count']); + $this->assertEquals(20.0, $result['local_parts'][1]['percentage']); + } + + public function testNormalizeWithMissingFields(): void + { + $data = [ + 'localParts' => [ + ['localPart' => 'john'], + ['count' => 50], + ['percentage' => 20.0], + [], + ], + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertCount(4, $result['local_parts']); + $this->assertEquals('john', $result['local_parts'][0]['local_part']); + $this->assertEquals(0, $result['local_parts'][0]['count']); + $this->assertEquals(0.0, $result['local_parts'][0]['percentage']); + $this->assertEquals('', $result['local_parts'][1]['local_part']); + $this->assertEquals(50, $result['local_parts'][1]['count']); + $this->assertEquals(0.0, $result['local_parts'][1]['percentage']); + $this->assertEquals('', $result['local_parts'][2]['local_part']); + $this->assertEquals(0, $result['local_parts'][2]['count']); + $this->assertEquals(20.0, $result['local_parts'][2]['percentage']); + $this->assertEquals('', $result['local_parts'][3]['local_part']); + $this->assertEquals(0, $result['local_parts'][3]['count']); + $this->assertEquals(0.0, $result['local_parts'][3]['percentage']); + } + + public function testNormalizeWithEmptyLocalParts(): void + { + $data = [ + 'localParts' => [], + 'total' => 0, + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertEmpty($result['local_parts']); + } + + public function testNormalizeWithNoLocalParts(): void + { + $data = [ + 'total' => 100, + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(100, $result['total']); + $this->assertEmpty($result['local_parts']); + } + + public function testNormalizeWithInvalidObject(): void + { + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize('not an array'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testSupportsNormalization(): void + { + $normalizer = new TopLocalPartsNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization([], null, ['top_local_parts' => true])); + $this->assertFalse($normalizer->supportsNormalization([], null, [])); + $this->assertFalse($normalizer->supportsNormalization('not an array', null, ['top_local_parts' => true])); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), null, ['top_local_parts' => true])); + } +}