diff --git a/.coderabbit.yaml b/.coderabbit.yaml index edaddc74..0577bc26 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -9,12 +9,11 @@ reviews: high_level_summary_in_walkthrough: false changed_files_summary: false poem: false + finishing_touches: + docstrings: + enabled: false auto_review: enabled: true base_branches: - ".*" drafts: false - -checks: - docstring_coverage: - enabled: false diff --git a/README.md b/README.md index e6b706f8..9a9f6b77 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,11 @@ contribute and how to run the unit tests and style checks locally. This project adheres to a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project and its community, you are expected to uphold this code. + + +### Code style checks +```bash +vendor/bin/phpstan analyse -l 5 src/ tests/ +vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml +vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/ +``` diff --git a/composer.json b/composer.json index c4c880f8..477d302b 100644 --- a/composer.json +++ b/composer.json @@ -42,17 +42,20 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", "zircote/swagger-php": "^4.11", "ext-dom": "*", - "tatevikgr/rss-feed": "dev-main as 0.1.0" + "tatevikgr/rss-feed": "dev-main as 0.1.0", + "psr/simple-cache": "^3.0", + "symfony/expression-language": "^6.4", + "nelmio/cors-bundle": "^2.4" }, "require-dev": { "phpunit/phpunit": "^10.0", - "guzzlehttp/guzzle": "^6.3.0", + "guzzlehttp/guzzle": "^7.2.0", "squizlabs/php_codesniffer": "^3.2.0", "phpstan/phpstan": "^1.10", "nette/caching": "^3.0.0", diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 2179f6ad..b6176579 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -4,6 +4,10 @@ services: autoconfigure: true public: false + _instanceof: + Symfony\Component\Serializer\Normalizer\NormalizerInterface: + tags: [ 'serializer.normalizer' ] + Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter: ~ Symfony\Component\Serializer\Normalizer\ObjectNormalizer: @@ -11,97 +15,5 @@ services: $classMetadataFactory: '@?serializer.mapping.class_metadata_factory' $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter' - PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberOnlyNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdministratorTokenNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberListNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberHistoryNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriptionNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\MessageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\TemplateImageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\TemplateNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\ListMessageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdminAttributeDefinitionNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdminAttributeValueNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\AttributeDefinitionNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberAttributeValueNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Common\Serializer\CursorPaginationNormalizer: - autowire: true - - 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 - - PhpList\RestBundle\Subscription\Serializer\UserBlacklistNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\BounceRegexNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true + PhpList\RestBundle\: + resource: '../../src/*/Serializer/*' diff --git a/config/services/services.yml b/config/services/services.yml index f087aa6e..17127575 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,8 +1,19 @@ services: - PhpList\RestBundle\Subscription\Service\SubscriberService: + PhpList\RestBundle\Subscription\Service\SubscriberHistoryService: autowire: true autoconfigure: true - PhpList\RestBundle\Subscription\Service\SubscriberHistoryService: + PhpList\Core\Domain\Messaging\Service\ForwardingGuard: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Service\ForwardDeliveryService: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Service\ForwardContentService: autowire: true autoconfigure: true + public: false diff --git a/config/services/validators.yml b/config/services/validators.yml index e7302414..a4fa508e 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -41,3 +41,13 @@ services: autowire: true autoconfigure: true + PhpList\RestBundle\Messaging\Validator\Constraint\MaxForwardCountValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + + PhpList\RestBundle\Messaging\Validator\Constraint\MaxPersonalNoteSizeValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + diff --git a/src/Common/Dto/CursorPaginationResult.php b/src/Common/Dto/CursorPaginationResult.php index 04fca6d3..bba0f1e6 100644 --- a/src/Common/Dto/CursorPaginationResult.php +++ b/src/Common/Dto/CursorPaginationResult.php @@ -7,9 +7,24 @@ class CursorPaginationResult { public function __construct( - public readonly array $items, - public readonly int $limit, - public readonly int $total, + private readonly array $items, + private readonly int $limit, + private readonly int $total, ) { } + + public function getItems(): array + { + return $this->items; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getTotal(): int + { + return $this->total; + } } diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index b898defa..f2724628 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -6,6 +6,9 @@ use Exception; use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; +use PhpList\Core\Domain\Messaging\Exception\AttachmentFileNotFoundException; +use PhpList\Core\Domain\Messaging\Exception\MessageNotReceivedException; +use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException; use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use Symfony\Component\HttpFoundation\JsonResponse; @@ -17,53 +20,49 @@ class ExceptionListener { + private const EXCEPTION_STATUS_MAP = [ + SubscriptionCreationException::class => null, + AttributeDefinitionCreationException::class => null, + AdminAttributeCreationException::class => null, + ValidatorException::class => 400, + AccessDeniedException::class => 403, + AccessDeniedHttpException::class => 403, + AttachmentFileNotFoundException::class => 404, + SubscriberNotFoundException::class => 404, + MessageNotReceivedException::class => 422, + ]; + public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); - if ($exception instanceof AccessDeniedHttpException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], 403); - - $event->setResponse($response); - } elseif ($exception instanceof HttpExceptionInterface) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], $exception->getStatusCode()); + foreach (self::EXCEPTION_STATUS_MAP as $class => $statusCode) { + if ($exception instanceof $class) { + $status = $statusCode ?? $exception->getStatusCode(); + $event->setResponse( + new JsonResponse([ + 'message' => $exception->getMessage() + ], $status) + ); + return; + } + } - $event->setResponse($response); - } elseif ($exception instanceof SubscriptionCreationException) { - $response = new JsonResponse([ - '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 AttributeDefinitionCreationException) { - $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(), - ], 500); + if ($exception instanceof HttpExceptionInterface) { + $event->setResponse( + new JsonResponse([ + 'message' => $exception->getMessage() + ], $exception->getStatusCode()) + ); + return; + } - $event->setResponse($response); + if ($exception instanceof Exception) { + $event->setResponse( + new JsonResponse([ + 'message' => $exception->getMessage() + ], 500) + ); } } } diff --git a/src/Common/Serializer/CursorPaginationNormalizer.php b/src/Common/Serializer/CursorPaginationNormalizer.php index cefd0842..8aad046d 100644 --- a/src/Common/Serializer/CursorPaginationNormalizer.php +++ b/src/Common/Serializer/CursorPaginationNormalizer.php @@ -15,9 +15,9 @@ class CursorPaginationNormalizer implements NormalizerInterface */ public function normalize($object, string $format = null, array $context = []): array { - $items = $object->items; - $limit = $object->limit; - $total = $object->total; + $items = $object->getItems(); + $limit = $object->getLimit(); + $total = $object->getTotal(); $hasNext = !empty($items) && isset($items[array_key_last($items)]['id']); return [ diff --git a/src/Common/Service/Provider/PaginatedDataProvider.php b/src/Common/Service/Provider/PaginatedDataProvider.php index 92ac53d0..c4d96c7c 100644 --- a/src/Common/Service/Provider/PaginatedDataProvider.php +++ b/src/Common/Service/Provider/PaginatedDataProvider.php @@ -37,20 +37,23 @@ public function getPaginatedList( throw new RuntimeException('Repository not found'); } - $items = $repository->getFilteredAfterId( + $result = $repository->getFilteredAfterId( lastId: $pagination->afterId, limit: $pagination->limit, filter: $filter, ); - $total = $repository->count(); $normalizedItems = array_map( fn($item) => $normalizer->normalize($item, 'json'), - $items + $result->getItems() ); return $this->paginationNormalizer->normalize( - new CursorPaginationResult($normalizedItems, $pagination->limit, $total) + new CursorPaginationResult( + $normalizedItems, + $result->getLimit(), + $result->getTotal(), + ) ); } } diff --git a/src/Common/Validator/RequestValidator.php b/src/Common/Validator/RequestValidator.php index 36b2ff80..b4b797c3 100644 --- a/src/Common/Validator/RequestValidator.php +++ b/src/Common/Validator/RequestValidator.php @@ -23,7 +23,8 @@ public function __construct( public function validate(Request $request, string $dtoClass): RequestInterface { try { - $body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + $content = $request->getContent(); + $body = ($content !== '' && $content !== '0') ? json_decode($content, true, 512, JSON_THROW_ON_ERROR) : []; } catch (Throwable $e) { throw new BadRequestHttpException('Invalid JSON: ' . $e->getMessage()); } @@ -33,7 +34,7 @@ public function validate(Request $request, string $dtoClass): RequestInterface $routeParams['listId'] = (int) $routeParams['listId']; } - $data = array_merge($routeParams, $body ?? []); + $data = array_merge($routeParams, $request->query->all(), $body ?? []); try { /** @var RequestInterface $dto */ @@ -44,7 +45,9 @@ public function validate(Request $request, string $dtoClass): RequestInterface ['allow_extra_attributes' => true] ); } catch (Throwable $e) { - throw new BadRequestHttpException('Invalid request data: ' . $e->getMessage()); + throw new BadRequestHttpException( + 'Invalid request data: ' . $e->getMessage() . ' Data: ' . json_encode($data) + ); } return $this->validateDto($dto); diff --git a/src/DependencyInjection/PhpListRestExtension.php b/src/DependencyInjection/PhpListRestExtension.php index 6d548519..884d46ee 100644 --- a/src/DependencyInjection/PhpListRestExtension.php +++ b/src/DependencyInjection/PhpListRestExtension.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -16,7 +17,7 @@ * * @author Oliver Klee */ -class PhpListRestExtension extends Extension +class PhpListRestExtension extends Extension implements PrependExtensionInterface { /** * Loads a specific configuration. @@ -35,4 +36,34 @@ public function load(array $configs, ContainerBuilder $container): void $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); $loader->load('services.yml'); } + + /** + * @param ContainerBuilder $container + * + * @return void + */ + public function prepend(ContainerBuilder $container): void + { + $frontendBaseUrl = $container->getParameter('app.frontend_base_url'); + + $container->prependExtensionConfig('nelmio_cors', [ + 'defaults' => [ + 'allow_origin' => [$frontendBaseUrl], + 'allow_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + 'allow_headers' => ['Content-Type', 'Authorization', 'Origin', 'Accept', 'php-auth-pw'], + 'expose_headers' => ['X-Content-Type-Options', 'Content-Security-Policy', 'X-Frame-Options'], + 'max_age' => 3600, + ], + 'paths' => [ + '^/api/v2' => [ + 'origin_regex' => true, + 'allow_origin' => [$frontendBaseUrl], + 'allow_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + 'allow_headers' => ['Content-Type', 'Authorization', 'Origin', 'Accept', 'php-auth-pw'], + 'expose_headers' => ['X-Content-Type-Options', 'Content-Security-Policy', 'X-Frame-Options'], + 'max_age' => 3600, + ], + ], + ]); + } } diff --git a/src/Identity/Controller/AdminAttributeDefinitionController.php b/src/Identity/Controller/AdminAttributeDefinitionController.php index 4bf38643..41ba5a91 100644 --- a/src/Identity/Controller/AdminAttributeDefinitionController.php +++ b/src/Identity/Controller/AdminAttributeDefinitionController.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; -use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeDefinitionManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; diff --git a/src/Identity/Controller/AdminAttributeValueController.php b/src/Identity/Controller/AdminAttributeValueController.php index b8e1b5a4..5cbea428 100644 --- a/src/Identity/Controller/AdminAttributeValueController.php +++ b/src/Identity/Controller/AdminAttributeValueController.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\AdminAttributeValue; -use PhpList\Core\Domain\Identity\Service\AdminAttributeManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; diff --git a/src/Identity/Controller/AdministratorController.php b/src/Identity/Controller/AdministratorController.php index c1f173f1..83138038 100644 --- a/src/Identity/Controller/AdministratorController.php +++ b/src/Identity/Controller/AdministratorController.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Domain\Identity\Service\AdministratorManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; diff --git a/src/Identity/Controller/PasswordResetController.php b/src/Identity/Controller/PasswordResetController.php index a3527b07..b020814d 100644 --- a/src/Identity/Controller/PasswordResetController.php +++ b/src/Identity/Controller/PasswordResetController.php @@ -6,7 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Identity\Service\PasswordManager; +use PhpList\Core\Domain\Identity\Service\Manager\PasswordManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; diff --git a/src/Identity/Controller/SessionController.php b/src/Identity/Controller/SessionController.php index 66b49ce9..74b4be99 100644 --- a/src/Identity/Controller/SessionController.php +++ b/src/Identity/Controller/SessionController.php @@ -7,11 +7,12 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdministratorToken; -use PhpList\Core\Domain\Identity\Service\SessionManager; +use PhpList\Core\Domain\Identity\Service\Manager\SessionManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Identity\Request\CreateSessionRequest; +use PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer; use PhpList\RestBundle\Identity\Serializer\AdministratorTokenNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; @@ -36,6 +37,7 @@ public function __construct( RequestValidator $validator, SessionManager $sessionManager, private readonly EntityManagerInterface $entityManager, + private readonly AdministratorNormalizer $normalizer, ) { parent::__construct($authentication, $validator); @@ -170,4 +172,46 @@ public function deleteSession( return $this->json(null, Response::HTTP_NO_CONTENT); } + + #[Route('/me', name: 'me', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/sessions/me', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Get auth user data.', + summary: 'Get auth user data.', + tags: ['sessions'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Administrator found', + content: new OA\JsonContent(ref: '#/components/schemas/Administrator') + ), + new OA\Response( + response: 401, + description: 'Failure', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Not authorized.') + ] + ) + ) + ] + )] + public function getSessionUser(Request $request): JsonResponse + { + $administrator = $this->requireAuthentication($request); + + $json = $this->normalizer->normalize($administrator, 'json'); + + return $this->json($json, Response::HTTP_OK); + } } diff --git a/src/Messaging/Controller/AttachmentController.php b/src/Messaging/Controller/AttachmentController.php new file mode 100644 index 00000000..b7e90fe2 --- /dev/null +++ b/src/Messaging/Controller/AttachmentController.php @@ -0,0 +1,94 @@ + '\\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/attachments/download/{id}', + description: 'Download an attachment by ID. `uid` query parameter is required.', + summary: 'Download attachment', + tags: ['attachments'], + parameters: [ + new OA\Parameter( + name: 'id', + description: 'Attachment ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + new OA\Parameter( + name: 'uid', + description: 'Download token (subscriber email or word "forwarded")', + in: 'query', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'File stream'), + new OA\Response(response: 403, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] + public function download( + #[MapEntity(mapping: ['id' => 'id'])] Attachment $attachment, + #[MapQueryParameter] string $uid + ): StreamedResponse { + $downloadable = $this->attachmentDownloadService->getDownloadable($attachment, $uid); + + $headers = [ + 'Content-Type' => $downloadable->mimeType, + 'Content-Disposition' => HeaderUtils::makeDisposition( + disposition: ResponseHeaderBag::DISPOSITION_ATTACHMENT, + filename: $downloadable->filename + ), + ]; + + if ($downloadable->size !== null) { + $headers['Content-Length'] = (string) $downloadable->size; + } + + return new StreamedResponse( + callback: function () use ($downloadable) { + $stream = $downloadable->content; + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + while (!$stream->eof()) { + echo $stream->read(8192); + flush(); + } + }, + status: 200, + headers: $headers + ); + } +} diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index a047c9cd..dc2ac689 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -29,19 +29,14 @@ #[Route('/campaigns', name: 'campaign_')] class CampaignController extends BaseController { - private CampaignService $campaignService; - private MessageBusInterface $messageBus; - public function __construct( Authentication $authentication, RequestValidator $validator, - CampaignService $campaignService, - MessageBusInterface $messageBus, + private readonly CampaignService $campaignService, + private readonly MessageBusInterface $messageBus, private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); - $this->campaignService = $campaignService; - $this->messageBus = $messageBus; } #[Route('', name: 'get_list', methods: ['GET'])] diff --git a/src/Messaging/Controller/EmailForwardController.php b/src/Messaging/Controller/EmailForwardController.php new file mode 100644 index 00000000..c7e1210d --- /dev/null +++ b/src/Messaging/Controller/EmailForwardController.php @@ -0,0 +1,121 @@ + + */ +#[Route('/email-forward', name: 'email_forward_')] +class EmailForwardController extends BaseController +{ + public function __construct( + Authentication $authentication, + RequestValidator $validator, + private readonly EntityManagerInterface $entityManager, + private readonly MessageForwardService $messageForwardService, + private readonly ForwardingResultNormalizer $forwardingResultNormalizer, + ) { + parent::__construct($authentication, $validator); + } + + #[Route('/{messageId}', name: 'forward', requirements: ['messageId' => '\\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/email-forward/{messageId}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Queues forwarding of a campaign/message to provided recipient emails.', + summary: 'Forward a message to recipients.', + requestBody: new OA\RequestBody( + description: 'Forwarding payload', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ForwardMessageRequest') + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 202, + description: 'Accepted', + content: new OA\JsonContent(ref: '#/components/schemas/ForwardResult') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ) + ] + )] + public function forwardMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + // todo: per-subscriber forwarding limits + /** @var ForwardMessageRequest $forwardRequest */ + $forwardRequest = $this->validator->validate($request, ForwardMessageRequest::class); + + $result = $this->messageForwardService->forward( + messageForwardDto: new MessageForwardDto( + emails: $forwardRequest->recipients, + uid: $forwardRequest->uid, + fromName: $forwardRequest->fromName, + fromEmail: $forwardRequest->fromEmail, + note: $forwardRequest->note, + ), + campaign: $message, + ); + + $this->entityManager->flush(); + + return $this->json( + $this->forwardingResultNormalizer->normalize($result), + Response::HTTP_ACCEPTED + ); + } +} diff --git a/src/Messaging/Request/ForwardMessageRequest.php b/src/Messaging/Request/ForwardMessageRequest.php new file mode 100644 index 00000000..4ce3f4a5 --- /dev/null +++ b/src/Messaging/Request/ForwardMessageRequest.php @@ -0,0 +1,98 @@ + [ + new Assert\Email([]), + new Assert\Length(max: 255), + ], + ])] + public array $recipients = []; + + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 255)] + public string $uid; + + #[MaxPersonalNoteSize] + public ?string $note = null; + + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 255)] + public string $fromName; + + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Email] + #[Assert\Length(max: 255)] + public string $fromEmail; + + public function getDto(): array + { + return [ + 'recipients' => $this->recipients, + 'uid' => $this->uid, + 'note' => $this->note, + 'fromName' => $this->fromName, + 'fromEmail' => $this->fromEmail, + ]; + } +} diff --git a/src/Messaging/Serializer/ForwardingResultNormalizer.php b/src/Messaging/Serializer/ForwardingResultNormalizer.php new file mode 100644 index 00000000..61b85cf7 --- /dev/null +++ b/src/Messaging/Serializer/ForwardingResultNormalizer.php @@ -0,0 +1,71 @@ + $recipient->email, + 'status' => $recipient->status, + 'reason' => $recipient->reason, + ]; + }, $object->recipients); + + return [ + 'total_requested' => $object->totalRequested, + 'total_sent' => $object->totalSent, + 'total_failed' => $object->totalFailed, + 'total_already_sent' => $object->totalAlreadySent, + 'recipients' => $recipients, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof ForwardingResult; + } +} diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php index 5c1f3e60..7a2ead3b 100644 --- a/src/Messaging/Serializer/MessageNormalizer.php +++ b/src/Messaging/Serializer/MessageNormalizer.php @@ -33,12 +33,6 @@ properties: [ new OA\Property(property: 'html_formated', type: 'boolean'), new OA\Property(property: 'send_format', type: 'string', example: 'text', nullable: true), - new OA\Property( - property: 'format_options', - type: 'array', - items: new OA\Items(type: 'string'), - example: ['as_html', 'as_text'], - ), ], type: 'object' ), @@ -112,7 +106,6 @@ public function normalize($object, string $format = null, array $context = []): 'message_format' => [ 'html_formated' => $object->getFormat()->isHtmlFormatted(), 'send_format' => $object->getFormat()->getSendFormat(), - 'format_options' => $object->getFormat()->getFormatOptions() ], 'message_metadata' => [ 'status' => $object->getMetadata()->getStatus()->value, diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index 4bc9e3c6..90ec7d25 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -32,7 +32,12 @@ public function getMessages(Request $request, Administrator $administrator): arr { $filter = (new MessageFilter())->setOwner($administrator); - return $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter); + return $this->paginatedProvider->getPaginatedList( + request: $request, + normalizer: $this->normalizer, + className: Message::class, + filter: $filter + ); } public function getMessage(Message $message = null): array @@ -50,7 +55,10 @@ public function createMessage(CreateMessageRequest $createMessageRequest, Admini throw new AccessDeniedHttpException('You are not allowed to create campaigns.'); } - $data = $this->messageManager->createMessage($createMessageRequest->getDto(), $administrator); + $data = $this->messageManager->createMessage( + createMessageDto: $createMessageRequest->getDto(), + authUser: $administrator + ); return $this->normalizer->normalize($data); } @@ -69,9 +77,9 @@ public function updateMessage( } $data = $this->messageManager->updateMessage( - $updateMessageRequest->getDto(), - $message, - $administrator + updateMessageDto: $updateMessageRequest->getDto(), + message: $message, + authUser: $administrator ); return $this->normalizer->normalize($data); diff --git a/src/Messaging/Validator/Constraint/MaxForwardCount.php b/src/Messaging/Validator/Constraint/MaxForwardCount.php new file mode 100644 index 00000000..9c6e41db --- /dev/null +++ b/src/Messaging/Validator/Constraint/MaxForwardCount.php @@ -0,0 +1,13 @@ + $this->maxForward) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ limit }}', (string) $this->maxForward) + ->addViolation(); + } + } +} diff --git a/src/Messaging/Validator/Constraint/MaxPersonalNoteSize.php b/src/Messaging/Validator/Constraint/MaxPersonalNoteSize.php new file mode 100644 index 00000000..1b0c519b --- /dev/null +++ b/src/Messaging/Validator/Constraint/MaxPersonalNoteSize.php @@ -0,0 +1,13 @@ +maxSize === null || $this->maxSize <= 0) { + return; + } + + if (!is_string($value)) { + return; + } + + $sizeLimit = $this->maxSize * 2; + $length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + + if ($length > $sizeLimit) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ limit }}', (string) $sizeLimit) + ->addViolation(); + } + } +} diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index b2230ea5..338c4dc1 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Throwable; /** * This controller provides REST API to access analytics data. @@ -356,4 +357,163 @@ public function getTopLocalParts(Request $request): JsonResponse return $this->json($normalizedData, Response::HTTP_OK); } + + #[Route('/dashboard', name: 'dashboard_statistics', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/analytics/dashboard', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns dashboard cards with aggregate analytics metrics.', + summary: 'Gets dashboard analytics statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + 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: 'summary_statistics', + properties: [ + new OA\Property( + property: 'total_subscribers', + properties: [ + new OA\Property(property: 'value', type: 'integer', example: 48294), + new OA\Property( + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 12.5 + ), + ], + type: 'object' + ), + new OA\Property( + property: 'active_campaigns', + properties: [ + new OA\Property(property: 'value', type: 'integer', example: 12), + new OA\Property( + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 0 + ), + ], + type: 'object' + ), + new OA\Property( + property: 'open_rate', + properties: [ + new OA\Property( + property: 'value', + type: 'number', + format: 'float', + example: 12 + ), + new OA\Property( + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 0 + ), + ], + type: 'object' + ), + new OA\Property( + property: 'bounce_rate', + properties: [ + new OA\Property( + property: 'value', + type: 'number', + format: 'float', + example: 12 + ), + new OA\Property( + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 0 + ), + ], + type: 'object' + ), + ], + type: 'object' + ), + new OA\Property( + property: 'recent_campaigns', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'name', type: 'string', example: 'March Newsletter'), + new OA\Property( + property: 'status', + type: 'string', + example: 'sent', + nullable: true + ), + new OA\Property( + property: 'date', + type: 'string', + format: 'date', + example: '2026-03-15', + nullable: true + ), + new OA\Property(property: 'open_rate', type: 'string', example: '42.50%'), + new OA\Property(property: 'click_rate', type: 'string', example: '8.10%'), + ], + type: 'object' + ) + ), + new OA\Property( + property: 'campaign_performance', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property( + property: 'date', + type: 'string', + format: 'date', + example: '2026-03-19' + ), + new OA\Property(property: 'opens', type: 'integer', example: 234), + new OA\Property(property: 'clicks', type: 'integer', example: 57), + ], + type: 'object' + ) + ), + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getDashboardStatistics(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $response = [ + 'summary_statistics' => $this->analyticsService->getSummaryStatistics(), + 'recent_campaigns' => $this->analyticsService->getRecentCampaigns(), + 'campaign_performance' => $this->analyticsService->getCampaignPerformance(), + ]; + + return $this->json($response, Response::HTTP_OK); + } } diff --git a/src/Statistics/Controller/MessageOpenTrackController.php b/src/Statistics/Controller/MessageOpenTrackController.php new file mode 100644 index 00000000..ccd3a675 --- /dev/null +++ b/src/Statistics/Controller/MessageOpenTrackController.php @@ -0,0 +1,104 @@ +returnPixelResponse(); + } + + $metadata = [ + 'HTTP_USER_AGENT' => $request->server->get('HTTP_USER_AGENT'), + 'HTTP_REFERER' => $request->server->get('HTTP_REFERER'), + 'client_ip' => $request->getClientIp(), + ]; + + try { + $this->userMessageService->trackUserMessageView($uid, $messageId, $metadata); + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->error( + 'Failed to track user message view', + [ + 'exception' => $e, + 'message_id' => $messageId, + ] + ); + } + + return $this->returnPixelResponse(); + } + + private function returnPixelResponse(): Response + { + return new Response( + content: base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='), + status: 200, + headers: [ + 'Content-Type' => 'image/gif', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma' => 'no-cache', + 'Expires' => '0', + ] + ); + } +} diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index cfd18d51..18f7f2ba 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -12,7 +12,9 @@ use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; +use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Subscription\Request\CreateSubscriberRequest; +use PhpList\RestBundle\Subscription\Request\SubscribersFilterRequest; use PhpList\RestBundle\Subscription\Request\UpdateSubscriberRequest; use PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer; use PhpList\RestBundle\Subscription\Service\SubscriberHistoryService; @@ -39,11 +41,123 @@ public function __construct( private readonly SubscriberNormalizer $subscriberNormalizer, private readonly SubscriberHistoryService $subscriberHistoryService, private readonly EntityManagerInterface $entityManager, + private readonly PaginatedDataProvider $paginatedDataProvider, ) { parent::__construct($authentication, $validator); $this->authentication = $authentication; } + #[Route('', name: 'get_list', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribers', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns a JSON list of all subscribers.', + summary: 'Gets a list of all subscribers.', + tags: ['subscribers'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: '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) + ), + new OA\Parameter( + name: 'is_confirmed', + description: 'Filter by confirmed status', + in: 'query', + required: false, + schema: new OA\Schema( + type: 'string', + enum: ['true', 'false', '0', '1'], + example: '1' + ) + ), + new OA\Parameter( + name: 'is_blacklisted', + description: 'Filter by blacklisted status', + in: 'query', + required: false, + schema: new OA\Schema( + type: 'string', + enum: ['true', 'false', '0', '1'], + example: '1' + ) + ), + new OA\Parameter( + name: 'find_column', + description: 'Column to search in (requires find_value)', + in: 'query', + required: false, + schema: new OA\Schema( + type: 'string', + enum: ['email', 'foreignKey', 'uniqueId'], + example: 'email' + ) + ), + new OA\Parameter( + name: 'find_value', + description: 'Value to search for (requires find_column)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', example: 'email@example.com') + ), + ], + 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') + ) + ] + )] + public function getSubscribers(Request $request): JsonResponse + { + $this->requireAuthentication($request); + + /** @var SubscribersFilterRequest $subscriberRequest */ + $subscriberRequest = $this->validator->validate($request, SubscribersFilterRequest::class); + + return $this->json( + data: $this->paginatedDataProvider->getPaginatedList( + request: $request, + normalizer: $this->subscriberNormalizer, + className: Subscriber::class, + filter: $subscriberRequest->getDto(), + ), + status: Response::HTTP_OK + ); + } + #[Route('', name: 'create', methods: ['POST'])] #[OA\Post( path: '/api/v2/subscribers', @@ -225,7 +339,7 @@ public function getSubscriber(Request $request, int $subscriberId): JsonResponse { $this->requireAuthentication($request); - $subscriber = $this->subscriberManager->getSubscriberById($subscriberId); + $subscriber = $this->subscriberManager->getSubscriberDetails($subscriberId); $subscriberData = $this->subscriberNormalizer->normalize($subscriber); return $this->json($subscriberData, Response::HTTP_OK); diff --git a/src/Subscription/Request/SubscribersFilterRequest.php b/src/Subscription/Request/SubscribersFilterRequest.php new file mode 100644 index 00000000..b974b32c --- /dev/null +++ b/src/Subscription/Request/SubscribersFilterRequest.php @@ -0,0 +1,56 @@ +normalizeBoolean($this->isConfirmed), + isBlacklisted: $this->normalizeBoolean($this->isBlacklisted), + findColumn: $this->findColumn, + findValue: $this->findColumn ? $this->findValue : null, + ); + } + + private function normalizeBoolean(mixed $value): ?bool + { + if ($value === null) { + return null; + } + + if (is_bool($value)) { + return $value; + } + + return (bool) filter_var($value, FILTER_VALIDATE_BOOLEAN); + } +} diff --git a/src/Subscription/Request/UpdateSubscriberRequest.php b/src/Subscription/Request/UpdateSubscriberRequest.php index 8a4a16fd..7c79bd22 100644 --- a/src/Subscription/Request/UpdateSubscriberRequest.php +++ b/src/Subscription/Request/UpdateSubscriberRequest.php @@ -20,7 +20,6 @@ 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'), ], type: 'object' )] @@ -43,9 +42,6 @@ class UpdateSubscriberRequest implements RequestInterface #[Assert\Type(type: 'bool')] public bool $disabled; - #[Assert\Type(type: 'string')] - public string $additionalData; - public function getDto(): UpdateSubscriberDto { return new UpdateSubscriberDto( @@ -54,7 +50,6 @@ public function getDto(): UpdateSubscriberDto blacklisted: $this->blacklisted, htmlEmail: $this->htmlEmail, disabled: $this->disabled, - additionalData: $this->additionalData, ); } } diff --git a/src/Subscription/Serializer/SubscriberNormalizer.php b/src/Subscription/Serializer/SubscriberNormalizer.php index d64d7750..6cfbc827 100644 --- a/src/Subscription/Serializer/SubscriberNormalizer.php +++ b/src/Subscription/Serializer/SubscriberNormalizer.php @@ -6,6 +6,7 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; use PhpList\Core\Domain\Subscription\Model\Subscription; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -20,10 +21,17 @@ format: 'date-time', example: '2023-01-01T12:00:00Z', ), + new OA\Property( + property: 'updated_at', + type: 'string', + format: 'date-time', + example: '2026-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: '69f4e92cf50eafca9627f35704f030f4'), + new OA\Property(property: 'uuid', type: 'string', example: '69f4e92-cf50eaf-ca9627f-35704f-030f4'), new OA\Property(property: 'html_email', type: 'boolean', example: true), new OA\Property(property: 'disabled', type: 'boolean', example: false), new OA\Property( @@ -31,13 +39,20 @@ type: 'array', items: new OA\Items(ref: '#/components/schemas/SubscriberList') ), + new OA\Property( + property: 'history', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscriberHistory') + ), ], type: 'object' )] class SubscriberNormalizer implements NormalizerInterface { - public function __construct(private readonly SubscriberListNormalizer $subscriberListNormalizer) - { + public function __construct( + private readonly SubscriberListNormalizer $subscriberListNormalizer, + private readonly SubscriberHistoryNormalizer $subscriberHistoryNormalizer, + ) { } /** @@ -53,15 +68,20 @@ public function normalize($object, string $format = null, array $context = []): 'id' => $object->getId(), 'email' => $object->getEmail(), 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), + 'updated_at' => $object->getUpdatedAt()->format('Y-m-d\TH:i:sP'), 'confirmed' => $object->isConfirmed(), 'blacklisted' => $object->isBlacklisted(), 'bounce_count' => $object->getBounceCount(), 'unique_id' => $object->getUniqueId(), + 'uuid' => $object->getUuid(), 'html_email' => $object->hasHtmlEmail(), 'disabled' => $object->isDisabled(), 'subscribed_lists' => array_map(function (Subscription $subscription) { return $this->subscriberListNormalizer->normalize($subscription->getSubscriberList()); }, $object->getSubscriptions()->toArray()), + 'history' => array_map(function (SubscriberHistory $history) { + return $this->subscriberHistoryNormalizer->normalize($history); + }, $object->getHistory()), ]; } diff --git a/tests/Helpers/DummyDomainModel.php b/tests/Helpers/DummyDomainModel.php new file mode 100644 index 00000000..0a94e27e --- /dev/null +++ b/tests/Helpers/DummyDomainModel.php @@ -0,0 +1,16 @@ + 1, 'name' => 'Item 1'], - (object)['id' => 2, 'name' => 'Item 2'], - ]; - } - - public function count(array $criteria = []): int - { - return 10; + return new PaginatedResult( + [ + new DummyDomainModel(1, 'Item 1'), + new DummyDomainModel(2, 'Item 2'), + ], + 2, + 10, + 2, + ); } } diff --git a/tests/Integration/Common/AbstractTestController.php b/tests/Integration/Common/AbstractTestController.php index e431abbd..e8d96b85 100644 --- a/tests/Integration/Common/AbstractTestController.php +++ b/tests/Integration/Common/AbstractTestController.php @@ -60,6 +60,10 @@ protected function jsonRequest( ): Crawler { $serverWithContentType = $server; $serverWithContentType['CONTENT_TYPE'] = 'application/json'; + // Ensure the server knows the client expects JSON back as well + $serverWithContentType['HTTP_ACCEPT'] = 'application/json'; + // Mark as AJAX-style request to help some handlers choose JSON rendering + $serverWithContentType['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; return self::getClient()->request( $method, diff --git a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php index 4c73d3c7..08ed439e 100644 --- a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php +++ b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php @@ -18,13 +18,11 @@ public function testControllerIsAvailableViaContainer(): void ); } - public function testRequestPasswordResetWithNoJsonReturnsError400(): void + public function testRequestPasswordResetWithNoJsonReturnsError422(): void { $this->jsonRequest('post', '/api/v2/password-reset/request'); - $this->assertHttpBadRequest(); - $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('Invalid JSON:', $data['message']); + $this->assertHttpUnprocessableEntity(); } public function testRequestPasswordResetWithInvalidEmailReturnsError422(): void @@ -55,13 +53,11 @@ public function testRequestPasswordResetWithValidEmailReturnsSuccess(): void $this->assertHttpNoContent(); } - public function testValidateTokenWithNoJsonReturnsError400(): void + public function testValidateTokenWithNoJsonReturnsError422(): void { $this->jsonRequest('post', '/api/v2/password-reset/validate'); - $this->assertHttpBadRequest(); - $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('Invalid JSON:', $data['message']); + $this->assertHttpUnprocessableEntity(); } public function testValidateTokenWithInvalidTokenReturnsInvalidResult(): void @@ -75,13 +71,11 @@ public function testValidateTokenWithInvalidTokenReturnsInvalidResult(): void $this->assertFalse($data['valid']); } - public function testResetPasswordWithNoJsonReturnsError400(): void + public function testResetPasswordWithNoJsonReturnsError422(): void { $this->jsonRequest('post', '/api/v2/password-reset/reset'); - $this->assertHttpBadRequest(); - $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('Invalid JSON:', $data['message']); + $this->assertHttpUnprocessableEntity(); } public function testResetPasswordWithInvalidTokenReturnsBadRequest(): void diff --git a/tests/Integration/Identity/Controller/SessionControllerTest.php b/tests/Integration/Identity/Controller/SessionControllerTest.php index 71a811c6..2003593c 100644 --- a/tests/Integration/Identity/Controller/SessionControllerTest.php +++ b/tests/Integration/Identity/Controller/SessionControllerTest.php @@ -42,13 +42,11 @@ public function testGetSessionsIsNotAllowed() $this->assertHttpMethodNotAllowed(); } - public function testPostSessionsWithNoJsonReturnsError400() + public function testPostSessionsWithNoJsonReturnsError422() { $this->jsonRequest('post', '/api/v2/sessions'); - $this->assertHttpBadRequest(); - $data = $this->getDecodedJsonResponseContent(); - $this->assertStringContainsString('Invalid JSON:', $data['message']); + $this->assertHttpUnprocessableEntity(); } public function testPostSessionsWithInvalidJsonWithJsonContentTypeReturnsError400() @@ -229,4 +227,32 @@ public function testDeleteSessionWithNoSuchSessionReturns404(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/sessions/999999'); $this->assertHttpNotFound(); } + + public function testGetSessionUserWithoutAuthenticationReturnsForbiddenStatus(): void + { + self::getClient()->request('GET', '/api/v2/sessions/me'); + + $this->assertHttpForbidden(); + } + + public function testGetSessionUserWithAuthenticationReturnsOkayStatus(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + $this->authenticatedJsonRequest('GET', '/api/v2/sessions/me'); + + $this->assertHttpOkay(); + } + + public function testGetSessionUserWithAuthenticationReturnsAdministratorData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + $this->authenticatedJsonRequest('GET', '/api/v2/sessions/me'); + + $data = $this->getDecodedJsonResponseContent(); + + self::assertSame(1, $data['id']); + self::assertSame('john.doe', $data['login_name']); + self::assertSame('john@example.com', $data['email']); + self::assertTrue($data['super_user']); + } } diff --git a/tests/Integration/Messaging/Controller/AttachmentControllerTest.php b/tests/Integration/Messaging/Controller/AttachmentControllerTest.php new file mode 100644 index 00000000..e00bc7c5 --- /dev/null +++ b/tests/Integration/Messaging/Controller/AttachmentControllerTest.php @@ -0,0 +1,125 @@ +repoPath = (string) self::getContainer()->getParameter('phplist.attachment_repository_path'); + if (!is_dir($this->repoPath)) { + mkdir($this->repoPath, 0777, true); + } + } + + protected function tearDown(): void + { + // Clean up any test file we might have created + $file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME; + if (is_file($file)) { + unlink($file); + } + + parent::tearDown(); + } + + public function testControllerIsAvailableViaContainer(): void + { + self::assertInstanceOf( + AttachmentController::class, + self::getContainer()->get(AttachmentController::class) + ); + } + + public function testDownloadReturnsFileStreamWithHeaders(): void + { + $this->loadFixtures([AttachmentFixture::class]); + + // Prepare the actual file in the repository path + $content = 'Hello Attachment'; + $file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME; + file_put_contents($file, $content); + + self::getClient()->request( + 'GET', + sprintf( + '/api/v2/attachments/download/%d?uid=%s', + AttachmentFixture::ATTACHMENT_ID, + Attachment::FORWARD + ) + ); + + $response = self::getClient()->getResponse(); + + // StreamedResponse should be 200 with correct headers + self::assertSame(200, $response->getStatusCode()); + self::assertSame('text/plain; charset=UTF-8', $response->headers->get('Content-Type')); + self::assertStringContainsString( + 'attachment; filename=' . AttachmentFixture::FILENAME, + (string) $response->headers->get('Content-Disposition') + ); + self::assertSame((string) strlen($content), $response->headers->get('Content-Length')); + + $callback = $response->getCallback(); + ob_start(); + $callback(); + $body = ob_get_clean(); + + self::assertSame($content, $body); + } + + public function testDownloadReturnsNotFoundWhenAttachmentEntityMissing(): void + { + self::getClient()->request('GET', '/api/v2/attachments/download/999999?uid=' . Attachment::FORWARD); + $this->assertHttpNotFound(); + } + + public function testDownloadReturnsNotFoundWhenUidEmailNotFound(): void + { + $this->loadFixtures([AttachmentFixture::class]); + + // Do not create the file; the uid validation happens first and should 404 + self::getClient()->request( + 'GET', + sprintf( + '/api/v2/attachments/download/%d?uid=%s', + AttachmentFixture::ATTACHMENT_ID, + 'does-not-exist@example.com' + ) + ); + + $this->assertHttpNotFound(); + } + + public function testDownloadReturnsNotFoundWhenFileMissing(): void + { + $this->loadFixtures([AttachmentFixture::class]); + + // Ensure no file exists + $file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME; + if (is_file($file)) { + unlink($file); + } + + self::getClient()->request( + 'GET', + sprintf( + '/api/v2/attachments/download/%d?uid=%s', + AttachmentFixture::ATTACHMENT_ID, + Attachment::FORWARD + ) + ); + + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Messaging/Controller/EmailForwardControllerTest.php b/tests/Integration/Messaging/Controller/EmailForwardControllerTest.php new file mode 100644 index 00000000..e3f1ac51 --- /dev/null +++ b/tests/Integration/Messaging/Controller/EmailForwardControllerTest.php @@ -0,0 +1,81 @@ +get(EmailForwardController::class) + ); + } + + public function testForwardWithInvalidPayloadReturnsUnprocessableEntity(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + // Missing required 'recipients' field should trigger 422 from RequestValidator + $this->authenticatedJsonRequest('POST', '/api/v2/email-forward/1', content: json_encode([ ])); + + $this->assertHttpUnprocessableEntity(); + } + + public function testForwardWithValidDataButNotReceivedEmail(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $payload = json_encode([ + 'recipients' => ['friend1@example.com'], + 'uid' => 'fwd-123', + 'note' => null, + 'from_name' => 'Alice', + 'from_email' => 'alice@example.com', + ]); + + $this->authenticatedJsonRequest('POST', '/api/v2/email-forward/1', content: $payload); + + $response = self::getClient()->getResponse(); + $this->assertHttpUnprocessableEntity(); + self::assertStringContainsString('application/json', (string)$response->headers); + + $data = $this->getDecodedJsonResponseContent(); + self::assertIsArray($data); + self::assertArrayHasKey('message', $data); + self::assertStringContainsString('Cannot forward: user has not received this message', $data['message']); + } + + public function testForwardWithInvalidEmailInRecipientsReturnsUnprocessableEntity(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $payload = json_encode([ + 'recipients' => ['not-an-email'], + ]); + + $this->authenticatedJsonRequest('POST', '/api/v2/email-forward/1', content: $payload); + + $this->assertHttpUnprocessableEntity(); + } + + public function testForwardWithInvalidIdReturnsNotFound(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + $this->authenticatedJsonRequest('POST', '/api/v2/email-forward/9999', content: json_encode([ + 'recipients' => ['friend@example.com'], + ])); + + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Messaging/Fixtures/AttachmentFixture.php b/tests/Integration/Messaging/Fixtures/AttachmentFixture.php new file mode 100644 index 00000000..7d23319e --- /dev/null +++ b/tests/Integration/Messaging/Fixtures/AttachmentFixture.php @@ -0,0 +1,33 @@ +setSubjectId($attachment, self::ATTACHMENT_ID); + $manager->persist($attachment); + $manager->flush(); + } +} diff --git a/tests/Integration/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Messaging/Fixtures/MessageFixture.php index 42b33b51..f281546c 100644 --- a/tests/Integration/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Messaging/Fixtures/MessageFixture.php @@ -50,13 +50,8 @@ public function load(ObjectManager $manager): void $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'], - ])) + htmlFormatted: (bool)$row['htmlformatted'], + sendFormat: $row['sendformat'], ); $schedule = new MessageSchedule( diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php index 6e23329a..c2e74ddb 100644 --- a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -254,4 +254,38 @@ public function testGetTopLocalPartsWithInvalidLimitParameter(): void self::assertArrayHasKey('local_parts', $response); self::assertIsArray($response['local_parts']); } + + public function testGetDashboardStatisticsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/dashboard'); + $this->assertHttpForbidden(); + } + + public function testGetDashboardStatisticsWithValidSessionReturnsCardsData(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscriberFixture::class, + MessageFixture::class, + ]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard'); + $this->assertHttpOkay(); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('total_subscribers', $response); + self::assertArrayHasKey('active_campaigns', $response); + self::assertArrayHasKey('open_rate', $response); + self::assertArrayHasKey('bounce_rate', $response); + + foreach (['total_subscribers', 'active_campaigns', 'open_rate', 'bounce_rate'] as $metric) { + self::assertIsArray($response[$metric]); + self::assertArrayHasKey('value', $response[$metric]); + self::assertArrayHasKey('change_vs_last_month', $response[$metric]); + self::assertIsNumeric($response[$metric]['value']); + self::assertIsNumeric($response[$metric]['change_vs_last_month']); + } + } } diff --git a/tests/Integration/Statistics/Controller/MessageOpenTrackControllerTest.php b/tests/Integration/Statistics/Controller/MessageOpenTrackControllerTest.php new file mode 100644 index 00000000..011aded2 --- /dev/null +++ b/tests/Integration/Statistics/Controller/MessageOpenTrackControllerTest.php @@ -0,0 +1,74 @@ +get(MessageOpenTrackController::class) + ); + } + + public function testOpenGifReturnsTransparentGifWithNoCacheHeaders(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + UserMessageFixture::class, + ]); + + self::getClient()->request( + 'GET', + sprintf('/api/v2/t/open.gif?u=%s&m=%d', self::TEST_UID, self::TEST_MESSAGE_ID) + ); + + $response = self::getClient()->getResponse(); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('image/gif', $response->headers->get('Content-Type')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + self::assertSame('no-cache', $response->headers->get('Pragma')); + self::assertSame('0', $response->headers->get('Expires')); + + $expectedGif = base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='); + self::assertSame($expectedGif, $response->getContent()); + } + + public function testOpenGifReturnsGifEvenIfUidUnknown(): void + { + // No fixtures needed for this; the controller should still return the GIF even if tracking no-ops + self::getClient()->request('GET', '/api/v2/t/open.gif?u=unknown-uid&m=999999'); + + $response = self::getClient()->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('image/gif', $response->headers->get('Content-Type')); + } + + public function testOpenGifMissingParametersReturns200Anyway(): void + { + self::getClient()->request('GET', '/api/v2/t/open.gif'); + $status = self::getClient()->getResponse()->getStatusCode(); + + self::assertSame(200, $status); + } +} diff --git a/tests/Integration/Statistics/Fixtures/UserMessageFixture.php b/tests/Integration/Statistics/Fixtures/UserMessageFixture.php new file mode 100644 index 00000000..73acc93e --- /dev/null +++ b/tests/Integration/Statistics/Fixtures/UserMessageFixture.php @@ -0,0 +1,43 @@ +getRepository(Subscriber::class)->find(self::SUBSCRIBER_ID); + /** @var Message|null $message */ + $message = $manager->getRepository(Message::class)->find(self::MESSAGE_ID); + + // Doctrine may return null here when prerequisite fixtures are not loaded. + // PHPStan infers non-null from PHPDoc in some environments; suppress that false positive. + if ($subscriber === null || $message === null) { + // Pre-requisite fixtures aren't loaded; nothing to do. + return; + } + + $userMessage = new UserMessage($subscriber, $message); + $userMessage->setStatus(UserMessageStatus::Sent); + + $manager->persist($userMessage); + $manager->flush(); + } +} diff --git a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php index 93cf93d0..19e0ab6e 100644 --- a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php @@ -15,6 +15,7 @@ * Testcase. * * @author Oliver Klee + * @author Tatevik Grigoryan */ class SubscriberControllerTest extends AbstractTestController { @@ -35,13 +36,6 @@ public function testControllerIsAvailableViaContainer() ); } - public function testGetSubscribersIsNotAllowed() - { - self::getClient()->request('get', '/api/v2/subscribers'); - - $this->assertHttpMethodNotAllowed(); - } - public function testPostSubscribersWithoutSessionKeyReturnsForbiddenStatus() { $this->jsonRequest('post', '/api/v2/subscribers'); diff --git a/tests/Integration/Subscription/Controller/SubscriberListControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberListControllerTest.php index eba68217..5a10b769 100644 --- a/tests/Integration/Subscription/Controller/SubscriberListControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscriberListControllerTest.php @@ -18,6 +18,7 @@ * * @author Oliver Klee * @author Xheni Myrtaj + * @author Tatevik Grigoryan */ class SubscriberListControllerTest extends AbstractTestController { @@ -260,10 +261,12 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'id' => 1, 'email' => 'oliver@example.com', 'created_at' => '2016-07-22T15:01:17+00:00', + 'updated_at' => '2016-08-23T19:50:43+00:00', 'confirmed' => true, 'blacklisted' => true, 'bounce_count' => 17, 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e89', + 'uuid' => '', 'html_email' => true, 'disabled' => true, 'subscribed_lists' => [ @@ -278,14 +281,17 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'category' => '', ], ], + 'history' => [], ], [ 'id' => 2, 'email' => 'oliver1@example.com', 'created_at' => '2016-07-22T15:01:17+00:00', + 'updated_at' => '2016-08-23T19:50:43+00:00', 'confirmed' => true, 'blacklisted' => true, 'bounce_count' => 17, 'unique_id' => '95feb7fe7e06e6c11ca8d0c48cb46e87', + 'uuid' => '', 'html_email' => true, 'disabled' => true, 'subscribed_lists' => [ @@ -310,10 +316,11 @@ public function testGetListMembersWithCurrentSessionKeyForExistingListWithSubscr 'category' => 'news', ], ], + 'history' => [], ], ], 'pagination' => [ - 'total' => 3, + 'total' => 2, 'limit' => 25, 'has_more' => false, 'next_cursor' => 2, diff --git a/tests/Integration/Subscription/Fixtures/SubscriberFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberFixture.php index 6b49d0fc..e6f23f50 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberFixture.php @@ -37,10 +37,9 @@ public function load(ObjectManager $manager): void } $row = array_combine($headers, $data); - $subscriber = new Subscriber(); + $subscriber = new Subscriber($row['email']); $this->setSubjectId($subscriber, (int)$row['id']); - $subscriber->setEmail($row['email']); $subscriber->setConfirmed((bool) $row['confirmed']); $subscriber->setBlacklisted((bool) $row['blacklisted']); $subscriber->setBounceCount((int) $row['bouncecount']); diff --git a/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php b/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php index ed055553..6c61a3fe 100644 --- a/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php +++ b/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php @@ -36,15 +36,7 @@ public function testGetPaginatedListSuccess(): void $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); + ->with(0, 2); $entityManager->method('getRepository') ->willReturn($repository); diff --git a/tests/Unit/Messaging/Request/ForwardMessageRequestTest.php b/tests/Unit/Messaging/Request/ForwardMessageRequestTest.php new file mode 100644 index 00000000..aeb66366 --- /dev/null +++ b/tests/Unit/Messaging/Request/ForwardMessageRequestTest.php @@ -0,0 +1,51 @@ +recipients = ['friend1@example.com', 'friend2@example.com']; + $request->uid = 'fwd-123e4567-e89b-12d3-a456-426614174000'; + $request->note = 'Thought you might like this.'; + $request->fromName = 'Alice'; + $request->fromEmail = 'alice@example.com'; + + $dto = $request->getDto(); + + $this->assertIsArray($dto); + $this->assertSame(['friend1@example.com', 'friend2@example.com'], $dto['recipients']); + $this->assertSame('fwd-123e4567-e89b-12d3-a456-426614174000', $dto['uid']); + $this->assertSame('Thought you might like this.', $dto['note']); + $this->assertSame('Alice', $dto['fromName']); + $this->assertSame('alice@example.com', $dto['fromEmail']); + } + + public function testGetDtoHandlesNullables(): void + { + $request = new ForwardMessageRequest(); + + $request->recipients = ['friend@example.com']; + $request->uid = 'fwd-uid-1'; + $request->note = null; + $request->fromName = 'Bob'; + $request->fromEmail = 'bob@example.com'; + + $dto = $request->getDto(); + + $this->assertIsArray($dto); + $this->assertSame(['friend@example.com'], $dto['recipients']); + $this->assertSame('fwd-uid-1', $dto['uid']); + $this->assertNull($dto['note']); + $this->assertSame('Bob', $dto['fromName']); + $this->assertSame('bob@example.com', $dto['fromEmail']); + } +} diff --git a/tests/Unit/Messaging/Serializer/ForwardingResultNormalizerTest.php b/tests/Unit/Messaging/Serializer/ForwardingResultNormalizerTest.php new file mode 100644 index 00000000..45e65527 --- /dev/null +++ b/tests/Unit/Messaging/Serializer/ForwardingResultNormalizerTest.php @@ -0,0 +1,92 @@ +normalizer = new ForwardingResultNormalizer(); + } + + public function testSupportsNormalizationReturnsTrueForForwardingResult(): void + { + $result = new ForwardingResult( + totalRequested: 0, + totalSent: 0, + totalFailed: 0, + totalAlreadySent: 0, + recipients: [], + ); + + $this->assertTrue($this->normalizer->supportsNormalization($result)); + } + + public function testSupportsNormalizationReturnsFalseForOtherObjects(): void + { + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeMapsAllTopLevelCounts(): void + { + $result = new ForwardingResult( + totalRequested: 5, + totalSent: 3, + totalFailed: 1, + totalAlreadySent: 1, + recipients: [], + ); + + $data = $this->normalizer->normalize($result); + + $this->assertIsArray($data); + $this->assertSame(5, $data['total_requested']); + $this->assertSame(3, $data['total_sent']); + $this->assertSame(1, $data['total_failed']); + $this->assertSame(1, $data['total_already_sent']); + $this->assertSame([], $data['recipients']); + } + + public function testNormalizeMapsRecipientsWithNullableReason(): void + { + $r1 = new ForwardingRecipientResult('a@example.com', 'sent', null); + $r2 = new ForwardingRecipientResult('b@example.com', 'failed', 'precache_failed'); + + $result = new ForwardingResult( + totalRequested: 2, + totalSent: 1, + totalFailed: 1, + totalAlreadySent: 0, + recipients: [$r1, $r2], + ); + + $data = $this->normalizer->normalize($result); + + $this->assertCount(2, $data['recipients']); + $this->assertSame([ + 'email' => 'a@example.com', + 'status' => 'sent', + 'reason' => null, + ], $data['recipients'][0]); + $this->assertSame([ + 'email' => 'b@example.com', + 'status' => 'failed', + 'reason' => 'precache_failed', + ], $data['recipients'][1]); + } + + public function testNormalizeReturnsEmptyArrayForNonForwardingResult(): void + { + $data = $this->normalizer->normalize(new \stdClass()); + $this->assertSame([], $data); + } +} diff --git a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php index d90ef900..8d6856d9 100644 --- a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php +++ b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php @@ -19,7 +19,11 @@ class MessageNormalizerTest extends TestCase public function __construct(string $name) { parent::__construct($name); - $this->normalizer = new MessageNormalizer(new TemplateNormalizer(new TemplateImageNormalizer())); + $this->normalizer = new MessageNormalizer( + new TemplateNormalizer( + new TemplateImageNormalizer() + ) + ); } public function testSupportsNormalization(): void @@ -41,7 +45,6 @@ public function testNormalizeReturnsExpectedArray(): void $content = new Message\MessageContent('Subject', 'Text', 'TextMsg', 'Footer'); $format = new Message\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'); @@ -61,7 +64,12 @@ public function testNormalizeReturnsExpectedArray(): void new DateTime('2025-01-01T00:00:00+00:00') ); - $options = new Message\MessageOptions('from@example.com', 'to@example.com', 'reply@example.com', 'group'); + $options = new Message\MessageOptions( + 'from@example.com', + 'to@example.com', + 'reply@example.com', + 'group' + ); $message = $this->createMock(Message::class); $message->method('getId')->willReturn(1); @@ -79,7 +87,6 @@ public function testNormalizeReturnsExpectedArray(): void $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(Message\MessageStatus::Draft->value, $result['message_metadata']['status']); $this->assertSame('from@example.com', $result['message_options']['from_field']); } diff --git a/tests/Unit/Messaging/Validator/Constraint/MaxForwardCountValidatorTest.php b/tests/Unit/Messaging/Validator/Constraint/MaxForwardCountValidatorTest.php new file mode 100644 index 00000000..3360749d --- /dev/null +++ b/tests/Unit/Messaging/Validator/Constraint/MaxForwardCountValidatorTest.php @@ -0,0 +1,78 @@ +createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $validator = new MaxForwardCountValidator(5); + $validator->initialize($context); + + $constraint = new MaxForwardCount(); + $validator->validate('not-an-array', $constraint); + + $this->assertTrue(true); + } + + public function testTriggersViolationWhenUniqueCountExceedsLimit(): void + { + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once()) + ->method('setParameter') + ->with('{{ limit }}', '1') + ->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->with('You can forward to at most {{ limit }} recipients.') + ->willReturn($builder); + + $validator = new MaxForwardCountValidator(1); + $validator->initialize($context); + + $constraint = new MaxForwardCount(); + $emails = [ + ' A@Example.com ', + // duplicate after trim+lower + 'a@example.com', + 'b@example.com', + // ignored empty + '', + // ignored non-string + null, + // ignored non-string + 123, + ]; + + $validator->validate($emails, $constraint); + } + + public function testNoViolationWhenWithinLimit(): void + { + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $validator = new MaxForwardCountValidator(3); + $validator->initialize($context); + + $constraint = new MaxForwardCount(); + $emails = ['a@example.com', 'b@example.com', 'a@example.com']; + + $validator->validate($emails, $constraint); + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Messaging/Validator/Constraint/MaxPersonalNoteSizeValidatorTest.php b/tests/Unit/Messaging/Validator/Constraint/MaxPersonalNoteSizeValidatorTest.php new file mode 100644 index 00000000..dc38dcf1 --- /dev/null +++ b/tests/Unit/Messaging/Validator/Constraint/MaxPersonalNoteSizeValidatorTest.php @@ -0,0 +1,89 @@ +createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $validator = new MaxPersonalNoteSizeValidator(10); + $validator->initialize($context); + + $constraint = new MaxPersonalNoteSize(); + $validator->validate(null, $constraint); + $validator->validate('', $constraint); + + $this->assertTrue(true); + } + + public function testSkipsWhenMaxSizeIsNullOrNegative(): void + { + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $validatorNull = new MaxPersonalNoteSizeValidator(null); + $validatorNull->initialize($context); + $validatorNull->validate('anything', new MaxPersonalNoteSize()); + + $validatorNeg = new MaxPersonalNoteSizeValidator(-1); + $validatorNeg->initialize($context); + $validatorNeg->validate('anything', new MaxPersonalNoteSize()); + + $this->assertTrue(true); + } + + public function testNoViolationWhenWithinOrAtLimit(): void + { + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + // sizeLimit = 20 + $max = 10; + $validator = new MaxPersonalNoteSizeValidator($max); + $validator->initialize($context); + + $constraint = new MaxPersonalNoteSize(); + // exactly at limit + $within = str_repeat('a', 20); + $validator->validate($within, $constraint); + // below limit + $short = str_repeat('b', 5); + $validator->validate($short, $constraint); + + $this->assertTrue(true); + } + + public function testViolationWhenExceedsLimit(): void + { + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once()) + ->method('setParameter') + ->with('{{ limit }}', '4') + ->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->with('Your personal note must be at most {{ limit }} characters long.') + ->willReturn($builder); + // sizeLimit = 4 + $validator = new MaxPersonalNoteSizeValidator(2); + $validator->initialize($context); + + $constraint = new MaxPersonalNoteSize(); + // length 5 > 4 + $value = str_repeat('x', 5); + $validator->validate($value, $constraint); + } +} diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php index 452ff13b..7d21db68 100644 --- a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -442,4 +442,87 @@ public function testGetTopLocalPartsReturnsJsonResponse(): void 'total' => 1, ], json_decode($response->getContent(), true)); } + + public function testGetDashboardStatisticsWithoutStatisticsPrivilegeThrowsException(): 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->getDashboardStatistics($request); + } + + public function testGetDashboardStatisticsReturnsJsonResponse(): 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(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getSummaryStatistics') + ->willReturn([ + 'total_subscribers' => [ + 'value' => 80, + 'change_vs_last_month' => 10.5, + ], + 'active_campaigns' => [ + 'value' => 12, + 'change_vs_last_month' => -4.25, + ], + 'open_rate' => [ + 'value' => 40.0, + 'change_vs_last_month' => 3.3, + ], + 'bounce_rate' => [ + 'value' => 6.67, + 'change_vs_last_month' => -1.1, + ], + ]); + + $response = $this->controller->getDashboardStatistics($request); + + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals([ + 'total_subscribers' => [ + 'value' => 80, + 'change_vs_last_month' => 10.5, + ], + 'active_campaigns' => [ + 'value' => 12, + 'change_vs_last_month' => -4.25, + ], + 'open_rate' => [ + 'value' => 40.0, + 'change_vs_last_month' => 3.3, + ], + 'bounce_rate' => [ + 'value' => 6.67, + 'change_vs_last_month' => -1.1, + ], + ], json_decode($response->getContent(), true)); + } } diff --git a/tests/Unit/Subscription/Request/SubscribersFilterRequestTest.php b/tests/Unit/Subscription/Request/SubscribersFilterRequestTest.php new file mode 100644 index 00000000..cff13382 --- /dev/null +++ b/tests/Unit/Subscription/Request/SubscribersFilterRequestTest.php @@ -0,0 +1,79 @@ +isConfirmed = 'true'; + $request->isBlacklisted = 'false'; + $request->findColumn = 'email'; + $request->findValue = 'test@example.com'; + + $dto = $request->getDto(); + + $this->assertInstanceOf(SubscriberFilter::class, $dto); + $this->assertTrue($dto->getIsConfirmed()); + $this->assertFalse($dto->getIsBlacklisted()); + $this->assertEquals('email', $dto->getFindColumn()); + $this->assertEquals('test@example.com', $dto->getFindValue()); + } + + public function testGetDtoWithBooleanValues(): void + { + $request = new SubscribersFilterRequest(); + $request->isConfirmed = true; + $request->isBlacklisted = false; + + $dto = $request->getDto(); + + $this->assertTrue($dto->getIsConfirmed()); + $this->assertFalse($dto->getIsBlacklisted()); + } + + public function testGetDtoWithNumericStringValues(): void + { + $request = new SubscribersFilterRequest(); + $request->isConfirmed = '1'; + $request->isBlacklisted = '0'; + + $dto = $request->getDto(); + + $this->assertTrue($dto->getIsConfirmed()); + $this->assertFalse($dto->getIsBlacklisted()); + } + + public function testGetDtoReturnsCorrectDtoWithNullValues(): void + { + $request = new SubscribersFilterRequest(); + + $dto = $request->getDto(); + + $this->assertInstanceOf(SubscriberFilter::class, $dto); + $this->assertNull($dto->getIsConfirmed()); + $this->assertNull($dto->getIsBlacklisted()); + $this->assertNull($dto->getFindColumn()); + $this->assertNull($dto->getFindValue()); + } + + public function testGetDtoNullsFindColumnAndValueWhenOnlyValueProvided(): void + { + $request = new SubscribersFilterRequest(); + $request->findColumn = null; + $request->findValue = 'test@example.com'; + + $dto = $request->getDto(); + + $this->assertInstanceOf(SubscriberFilter::class, $dto); + $this->assertNull($dto->getFindColumn()); + $this->assertNull($dto->getFindValue()); + } +} diff --git a/tests/Unit/Subscription/Request/UpdateSubscriberRequestTest.php b/tests/Unit/Subscription/Request/UpdateSubscriberRequestTest.php index 87158e1b..71497e7c 100644 --- a/tests/Unit/Subscription/Request/UpdateSubscriberRequestTest.php +++ b/tests/Unit/Subscription/Request/UpdateSubscriberRequestTest.php @@ -18,7 +18,6 @@ public function testGetDtoReturnsCorrectDto(): void $request->blacklisted = false; $request->htmlEmail = true; $request->disabled = false; - $request->additionalData = 'Some additional data'; $dto = $request->getDto(); @@ -28,6 +27,5 @@ public function testGetDtoReturnsCorrectDto(): void $this->assertFalse($dto->blacklisted); $this->assertTrue($dto->htmlEmail); $this->assertFalse($dto->disabled); - $this->assertEquals('Some additional data', $dto->additionalData); } } diff --git a/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php index 5e8da09f..6dc741cc 100644 --- a/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php +++ b/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\RestBundle\Subscription\Serializer\SubscriberHistoryNormalizer; use PhpList\RestBundle\Subscription\Serializer\SubscriberListNormalizer; use PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer; use PHPUnit\Framework\TestCase; @@ -18,7 +19,7 @@ class SubscriberNormalizerTest extends TestCase { public function testSupportsNormalization(): void { - $normalizer = new SubscriberNormalizer(new SubscriberListNormalizer()); + $normalizer = new SubscriberNormalizer(new SubscriberListNormalizer(), new SubscriberHistoryNormalizer()); $subscriber = $this->createMock(Subscriber::class); $this->assertTrue($normalizer->supportsNormalization($subscriber)); @@ -46,20 +47,23 @@ public function testNormalize(): void $subscriber->method('isBlacklisted')->willReturn(false); $subscriber->method('getBounceCount')->willReturn(0); $subscriber->method('getUniqueId')->willReturn('abc123'); + $subscriber->method('getUuid')->willReturn('abc-123-abc-123'); $subscriber->method('hasHtmlEmail')->willReturn(true); $subscriber->method('isDisabled')->willReturn(false); $subscriber->method('getSubscriptions')->willReturn(new ArrayCollection([$subscription])); - $normalizer = new SubscriberNormalizer(new SubscriberListNormalizer()); + $normalizer = new SubscriberNormalizer(new SubscriberListNormalizer(), new SubscriberHistoryNormalizer()); $expected = [ 'id' => 101, 'email' => 'test@example.com', 'created_at' => '2024-12-31T12:00:00+00:00', + 'updated_at' => '', 'confirmed' => true, 'blacklisted' => false, 'bounce_count' => 0, 'unique_id' => 'abc123', + 'uuid' => 'abc-123-abc-123', 'html_email' => true, 'disabled' => false, 'subscribed_lists' => [ @@ -73,7 +77,8 @@ public function testNormalize(): void 'public' => true, 'category' => '', ] - ] + ], + 'history' => [], ]; $this->assertSame($expected, $normalizer->normalize($subscriber)); @@ -81,7 +86,7 @@ public function testNormalize(): void public function testNormalizeWithInvalidObject(): void { - $normalizer = new SubscriberNormalizer(new SubscriberListNormalizer()); + $normalizer = new SubscriberNormalizer(new SubscriberListNormalizer(), new SubscriberHistoryNormalizer()); $this->assertSame([], $normalizer->normalize(new stdClass())); } }