diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 170b9894..fa1d96c4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,6 +73,7 @@ jobs:
export PHPLIST_DATABASE_PASSWORD=${{ env.DB_PASSWORD }}
export PHPLIST_DATABASE_PORT=${{ job.services.mysql.ports['3306'] }}
export PHPLIST_DATABASE_HOST=127.0.0.1
+ export PHPLIST_DATABASE_PATH=
vendor/bin/phpunit tests/Integration/
continue-on-error: ${{matrix.php-versions == '8.0' }} # [temp-php8]
- name: Running static analysis
diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml
index 33eee07d..cbf6eead 100644
--- a/.github/workflows/client-docs.yml
+++ b/.github/workflows/client-docs.yml
@@ -11,6 +11,7 @@ on:
jobs:
generate-openapi:
+ if: (github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev')) || (github.event_name == 'pull_request' && github.base_ref == 'main')
runs-on: ubuntu-22.04
outputs:
source_branch: ${{ steps.branch.outputs.source_branch }}
diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml
index 0aac1038..0b4784ca 100644
--- a/.github/workflows/front-docs.yml
+++ b/.github/workflows/front-docs.yml
@@ -14,6 +14,7 @@ on:
- main
jobs:
generate-openapi:
+ if: (github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev')) || (github.event_name == 'pull_request' && github.base_ref == 'main')
runs-on: ubuntu-22.04
outputs:
source_branch: ${{ steps.branch.outputs.source_branch }}
diff --git a/.gitignore b/.gitignore
index 2c98e37b..a2318880 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@
/var/
/vendor/
.phpunit.result.cache
+.env
+.env.dist
diff --git a/composer.json b/composer.json
index 17764d64..b6059e58 100644
--- a/composer.json
+++ b/composer.json
@@ -42,7 +42,8 @@
},
"require": {
"php": "^8.1",
- "phplist/core": "dev-main",
+ "phplist/core": "dev-dev",
+ "elasticsearch/elasticsearch": "^8.9",
"friendsofsymfony/rest-bundle": "*",
"symfony/test-pack": "^1.0",
"symfony/process": "^6.4",
@@ -57,7 +58,7 @@
"phpunit/phpunit": "^10.0",
"guzzlehttp/guzzle": "^7.2.0",
"squizlabs/php_codesniffer": "^3.2.0",
- "phpstan/phpstan": "^1.10",
+ "phpstan/phpstan": "^2.2",
"nette/caching": "^3.0.0",
"nikic/php-parser": "^4.19.1",
"phpmd/phpmd": "^2.6.0",
@@ -85,6 +86,7 @@
"PhpList\\Core\\Composer\\ScriptHandler::createGeneralConfiguration",
"PhpList\\Core\\Composer\\ScriptHandler::createBundleConfiguration",
"PhpList\\Core\\Composer\\ScriptHandler::createRoutesConfiguration",
+ "PhpList\\Core\\Composer\\ScriptHandler::createDotenvConfiguration",
"PhpList\\Core\\Composer\\ScriptHandler::createParametersConfiguration",
"PhpList\\Core\\Composer\\ScriptHandler::clearAllCaches"
],
@@ -114,27 +116,32 @@
"rest-api-identity": {
"resource": "@PhpListRestBundle/Identity/Controller/",
"type": "attribute",
- "prefix": "/api/v2"
+ "prefix": "/api/v2",
+ "name_prefix": "api_"
},
"rest-api-subscription": {
"resource": "@PhpListRestBundle/Subscription/Controller/",
"type": "attribute",
- "prefix": "/api/v2"
+ "prefix": "/api/v2",
+ "name_prefix": "api_"
},
"rest-api-messaging": {
"resource": "@PhpListRestBundle/Messaging/Controller/",
"type": "attribute",
- "prefix": "/api/v2"
+ "prefix": "/api/v2",
+ "name_prefix": "api_"
},
"rest-api-configuration": {
"resource": "@PhpListRestBundle/Configuration/Controller/",
"type": "attribute",
- "prefix": "/api/v2"
+ "prefix": "/api/v2",
+ "name_prefix": "api_"
},
"rest-api-analitics": {
"resource": "@PhpListRestBundle/Statistics/Controller/",
"type": "attribute",
- "prefix": "/api/v2"
+ "prefix": "/api/v2",
+ "name_prefix": "api_"
}
}
}
diff --git a/config/services.yml b/config/services.yml
index 086d199b..9471764e 100644
--- a/config/services.yml
+++ b/config/services.yml
@@ -12,7 +12,7 @@ services:
calls:
- ['registerHandler', [ 'json', ['@my.secure_handler', 'createResponse'] ] ]
- PhpList\Core\Security\Authentication:
+ PhpList\Core\Domain\Identity\Service\Authentication:
autowire: true
autoconfigure: true
diff --git a/config/services/messenger_handlers.yml b/config/services/messenger_handlers.yml
index 6b5a0ee4..7580347c 100644
--- a/config/services/messenger_handlers.yml
+++ b/config/services/messenger_handlers.yml
@@ -1,10 +1,10 @@
services:
- PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler:
+ PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler:
autowire: true
autoconfigure: true
public: false
- PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\TestCampaignProcessorMessageHandler:
+ PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorTestMessageHandler:
autowire: true
autoconfigure: true
public: false
diff --git a/config/services/validators.yml b/config/services/validators.yml
index ba0b8826..30fb5b24 100644
--- a/config/services/validators.yml
+++ b/config/services/validators.yml
@@ -3,6 +3,7 @@ services:
arguments:
$serializer: '@phplist.request_serializer'
$validator: '@validator'
+ $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter'
PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmailValidator:
autowire: true
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 78ea4f9a..692cf4d0 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -5,10 +5,11 @@
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.2/phpunit.xsd"
backupGlobals="false"
colors="true"
- bootstrap="vendor/autoload.php"
+ bootstrap="tests/bootstrap.php"
>
+
diff --git a/src/Common/Controller/BaseController.php b/src/Common/Controller/BaseController.php
index 136216b4..a723ebe1 100644
--- a/src/Common/Controller/BaseController.php
+++ b/src/Common/Controller/BaseController.php
@@ -5,13 +5,13 @@
namespace PhpList\RestBundle\Common\Controller;
use PhpList\Core\Domain\Identity\Model\Administrator;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
-/** @SuppressWarnings(PHPMD.NumberOfChildren) */
+/** @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 184925a3..fb8f75c9 100644
--- a/src/Common/EventListener/ExceptionListener.php
+++ b/src/Common/EventListener/ExceptionListener.php
@@ -9,7 +9,7 @@
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\Common\Exception\AttributeDefinitionCreationException;
use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
use PhpList\Core\Domain\Common\Exception\InvalidUploadException;
use PhpList\Core\Domain\Common\Exception\MissingUploadException;
@@ -57,7 +57,7 @@ public function onKernelException(ExceptionEvent $event): void
new JsonResponse([
'message' => 'Validation failed',
'errors' => $this->parseFlatValidationMessage($exception->getMessage()),
- ], 422)
+ ], 422)
);
return;
diff --git a/src/Common/Serializer/CursorPaginationNormalizer.php b/src/Common/Serializer/CursorPaginationNormalizer.php
index 8aad046d..d0a12836 100644
--- a/src/Common/Serializer/CursorPaginationNormalizer.php
+++ b/src/Common/Serializer/CursorPaginationNormalizer.php
@@ -11,7 +11,7 @@ class CursorPaginationNormalizer implements NormalizerInterface
{
/**
* @param CursorPaginationResult $object
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -32,10 +32,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof CursorPaginationResult;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ CursorPaginationResult::class => true,
+ ];
+ }
}
diff --git a/src/Common/Validator/RequestValidator.php b/src/Common/Validator/RequestValidator.php
index 51cf30a7..947d2f34 100644
--- a/src/Common/Validator/RequestValidator.php
+++ b/src/Common/Validator/RequestValidator.php
@@ -8,6 +8,7 @@
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Throwable;
@@ -16,7 +17,8 @@ class RequestValidator
{
public function __construct(
private readonly DenormalizerInterface $serializer,
- private readonly ValidatorInterface $validator
+ private readonly ValidatorInterface $validator,
+ private readonly NameConverterInterface $nameConverter
) {
}
@@ -66,7 +68,7 @@ public function validateDto(RequestInterface $request): RequestInterface
foreach ($errors as $violation) {
$lines[] = sprintf(
'%s: %s',
- $violation->getPropertyPath(),
+ $this->nameConverter->normalize($violation->getPropertyPath()),
$violation->getMessage()
);
}
diff --git a/src/Configuration/Controller/ConfigController.php b/src/Configuration/Controller/ConfigController.php
index 52b5afe1..f19b51ee 100644
--- a/src/Configuration/Controller/ConfigController.php
+++ b/src/Configuration/Controller/ConfigController.php
@@ -9,8 +9,8 @@
use PhpList\Core\Domain\Configuration\Exception\ConfigNotEditableException;
use PhpList\Core\Domain\Configuration\Model\Config;
use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager;
-use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
+use PhpList\Core\Domain\Identity\Service\PermissionChecker;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Configuration\Request\CreateConfigRequest;
@@ -31,6 +31,7 @@ public function __construct(
private readonly ConfigManager $manager,
private readonly ConfigNormalizer $normalizer,
private readonly EntityManagerInterface $entityManager,
+ private readonly PermissionChecker $permissionChecker,
) {
parent::__construct($authentication, $validator);
}
@@ -76,7 +77,10 @@ public function __construct(
)]
public function list(Request $request): JsonResponse
{
- $this->denyUnlessSettingsAdmin($request, 'You are not allowed to view configuration.');
+ $admin = $this->requireAuthentication($request);
+ if (!$this->permissionChecker->canList($admin, Config::class)) {
+ throw $this->createAccessDeniedException('You are not allowed to view configuration.');
+ }
$items = $this->manager->getAllEditable();
usort(
@@ -148,10 +152,13 @@ public function getOne(
Request $request,
#[MapEntity(mapping: ['key' => 'key'])] ?Config $config,
): JsonResponse {
- $this->denyUnlessSettingsAdmin($request, 'You are not allowed to view configuration.');
+ $admin = $this->requireAuthentication($request);
if ($config === null) {
throw $this->createNotFoundException('Configuration item not found.');
}
+ if (!$this->permissionChecker->canView($admin, $config)) {
+ throw $this->createAccessDeniedException('You are not allowed to view configuration.');
+ }
return $this->json($this->normalizer->normalize($config), Response::HTTP_OK);
}
@@ -202,7 +209,10 @@ public function getOne(
)]
public function create(Request $request): JsonResponse
{
- $this->denyUnlessSettingsAdmin($request, 'You are not allowed to create configuration.');
+ $admin = $this->requireAuthentication($request);
+ if (!$this->permissionChecker->canCreate($admin, Config::class)) {
+ throw $this->createAccessDeniedException('You are not allowed to create configuration.');
+ }
/* @var CreateConfigRequest $configRequest */
$configRequest = $this->validator->validate($request, CreateConfigRequest::class);
@@ -267,10 +277,13 @@ public function update(
Request $request,
#[MapEntity(mapping: ['key' => 'key'])] ?Config $config = null
): JsonResponse {
- $this->denyUnlessSettingsAdmin($request, 'You are not allowed to update configuration.');
+ $admin = $this->requireAuthentication($request);
if ($config === null) {
throw $this->createNotFoundException('Configuration item not found.');
}
+ if (!$this->permissionChecker->canEdit($admin, $config)) {
+ throw $this->createAccessDeniedException('You are not allowed to update configuration.');
+ }
/* @var UpdateConfigRequest $dto */
$dto = $this->validator->validate($request, UpdateConfigRequest::class);
@@ -325,22 +338,17 @@ public function delete(
Request $request,
#[MapEntity(mapping: ['key' => 'key'])] ?Config $config = null
): JsonResponse {
- $this->denyUnlessSettingsAdmin($request, 'You are not allowed to delete configuration.');
+ $admin = $this->requireAuthentication($request);
if ($config === null) {
throw $this->createNotFoundException('Configuration item not found.');
}
+ if (!$this->permissionChecker->canDelete($admin, $config)) {
+ throw $this->createAccessDeniedException('You are not allowed to delete configuration.');
+ }
$this->manager->delete($config);
$this->entityManager->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
-
- private function denyUnlessSettingsAdmin(Request $request, string $message): void
- {
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Settings)) {
- throw $this->createAccessDeniedException($message);
- }
- }
}
diff --git a/src/Configuration/Serializer/ConfigNormalizer.php b/src/Configuration/Serializer/ConfigNormalizer.php
index 7ffa6975..03f1f01d 100644
--- a/src/Configuration/Serializer/ConfigNormalizer.php
+++ b/src/Configuration/Serializer/ConfigNormalizer.php
@@ -23,7 +23,7 @@ class ConfigNormalizer implements NormalizerInterface
/**
* Normalizes a configuration item.
*
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -42,10 +42,20 @@ public function normalize($object, string $format = null, array $context = []):
/**
* Checks whether the value can be normalized.
*
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Config;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ Config::class => true,
+ ];
+ }
}
diff --git a/src/Identity/Controller/AdminAttributeDefinitionController.php b/src/Identity/Controller/AdminAttributeDefinitionController.php
index f02d9590..c28a22a8 100644
--- a/src/Identity/Controller/AdminAttributeDefinitionController.php
+++ b/src/Identity/Controller/AdminAttributeDefinitionController.php
@@ -9,7 +9,7 @@
use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter;
use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition;
use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeDefinitionManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
diff --git a/src/Identity/Controller/AdminAttributeValueController.php b/src/Identity/Controller/AdminAttributeValueController.php
index c20cfe0c..ae9b1977 100644
--- a/src/Identity/Controller/AdminAttributeValueController.php
+++ b/src/Identity/Controller/AdminAttributeValueController.php
@@ -11,7 +11,7 @@
use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition;
use PhpList\Core\Domain\Identity\Model\AdminAttributeValue;
use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
diff --git a/src/Identity/Controller/AdministratorController.php b/src/Identity/Controller/AdministratorController.php
index 799cfd6f..7df21bcb 100644
--- a/src/Identity/Controller/AdministratorController.php
+++ b/src/Identity/Controller/AdministratorController.php
@@ -9,7 +9,7 @@
use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
@@ -257,7 +257,7 @@ public function updateAdministrator(
/** @var UpdateAdministratorRequest $updateRequest */
$updateRequest = $this->validator->validate(
request: $request,
- dtoClass:UpdateAdministratorRequest::class,
+ dtoClass: UpdateAdministratorRequest::class,
beforeValidation: static function (UpdateAdministratorRequest $dto) use ($administrator): void {
$dto->setUpdatingId($administrator->getId());
}
diff --git a/src/Identity/Controller/PasswordResetController.php b/src/Identity/Controller/PasswordResetController.php
index ded9ac7b..ed8b6fbd 100644
--- a/src/Identity/Controller/PasswordResetController.php
+++ b/src/Identity/Controller/PasswordResetController.php
@@ -7,7 +7,7 @@
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Identity\Service\Manager\PasswordManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Identity\Request\RequestPasswordResetRequest;
diff --git a/src/Identity/Controller/SessionController.php b/src/Identity/Controller/SessionController.php
index 78d0e51f..7fb5f2c3 100644
--- a/src/Identity/Controller/SessionController.php
+++ b/src/Identity/Controller/SessionController.php
@@ -8,7 +8,7 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
use PhpList\Core\Domain\Identity\Service\Manager\SessionManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Identity\Request\CreateSessionRequest;
diff --git a/src/Identity/Serializer/AdminAttributeDefinitionNormalizer.php b/src/Identity/Serializer/AdminAttributeDefinitionNormalizer.php
index 68be95a4..22e03c85 100644
--- a/src/Identity/Serializer/AdminAttributeDefinitionNormalizer.php
+++ b/src/Identity/Serializer/AdminAttributeDefinitionNormalizer.php
@@ -23,7 +23,7 @@
class AdminAttributeDefinitionNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -42,10 +42,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof AdminAttributeDefinition;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ AdminAttributeDefinition::class => true,
+ ];
+ }
}
diff --git a/src/Identity/Serializer/AdminAttributeValueNormalizer.php b/src/Identity/Serializer/AdminAttributeValueNormalizer.php
index d6a0fd72..cc403c3c 100644
--- a/src/Identity/Serializer/AdminAttributeValueNormalizer.php
+++ b/src/Identity/Serializer/AdminAttributeValueNormalizer.php
@@ -26,7 +26,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -42,10 +42,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof AdminAttributeValue;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ AdminAttributeValue::class => true,
+ ];
+ }
}
diff --git a/src/Identity/Serializer/AdministratorNormalizer.php b/src/Identity/Serializer/AdministratorNormalizer.php
index 751bfe20..d8ff29ea 100644
--- a/src/Identity/Serializer/AdministratorNormalizer.php
+++ b/src/Identity/Serializer/AdministratorNormalizer.php
@@ -30,7 +30,7 @@
class AdministratorNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
* @throws InvalidArgumentException
*/
public function normalize($object, string $format = null, array $context = []): array
@@ -45,15 +45,25 @@ public function normalize($object, string $format = null, array $context = []):
'email' => $object->getEmail(),
'super_user' => $object->isSuperUser(),
'privileges' => $object->getPrivileges()->all(),
- 'created_at' => $object->getCreatedAt()?->format(DateTimeInterface::ATOM),
+ 'created_at' => $object->getCreatedAt()->format(DateTimeInterface::ATOM),
];
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Administrator;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ Administrator::class => true,
+ ];
+ }
}
diff --git a/src/Identity/Serializer/AdministratorTokenNormalizer.php b/src/Identity/Serializer/AdministratorTokenNormalizer.php
index a04e8a7f..2ab5d1de 100644
--- a/src/Identity/Serializer/AdministratorTokenNormalizer.php
+++ b/src/Identity/Serializer/AdministratorTokenNormalizer.php
@@ -11,7 +11,7 @@
class AdministratorTokenNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -27,10 +27,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof AdministratorToken;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ AdministratorToken::class => true,
+ ];
+ }
}
diff --git a/src/Identity/Validator/Constraint/UniqueEmailValidator.php b/src/Identity/Validator/Constraint/UniqueEmailValidator.php
index 404ce13d..1e82d9ae 100644
--- a/src/Identity/Validator/Constraint/UniqueEmailValidator.php
+++ b/src/Identity/Validator/Constraint/UniqueEmailValidator.php
@@ -5,7 +5,6 @@
namespace PhpList\RestBundle\Identity\Validator\Constraint;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
-use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -34,10 +33,11 @@ public function validate($value, Constraint $constraint): void
$existingUser = $this->repository->findOneBy(['email' => $value]);
$dto = $this->context->getObject();
- $updatingId = $dto->administratorId ?? null;
+ $updatingId = $dto->updatingId ?? null;
if ($existingUser && $existingUser->getId() !== $updatingId) {
- throw new ConflictHttpException('Email already exists.');
+ $this->context->buildViolation($constraint->message)
+ ->addViolation();
}
}
}
diff --git a/src/Identity/Validator/Constraint/UniqueLoginNameValidator.php b/src/Identity/Validator/Constraint/UniqueLoginNameValidator.php
index 41f3f893..bf7fe87a 100644
--- a/src/Identity/Validator/Constraint/UniqueLoginNameValidator.php
+++ b/src/Identity/Validator/Constraint/UniqueLoginNameValidator.php
@@ -5,7 +5,6 @@
namespace PhpList\RestBundle\Identity\Validator\Constraint;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
-use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -40,7 +39,9 @@ public function validate($value, Constraint $constraint): void
$updatingId = $dto->updatingId ?? null;
if ($existingUser && $existingUser->getId() !== $updatingId) {
- throw new ConflictHttpException('Login already exists.');
+ $this->context->buildViolation($constraint->message)
+ ->setParameter('{{ value }}', $value)
+ ->addViolation();
}
}
}
diff --git a/src/Messaging/Controller/AttachmentController.php b/src/Messaging/Controller/AttachmentController.php
index b7e90fe2..c9754bbb 100644
--- a/src/Messaging/Controller/AttachmentController.php
+++ b/src/Messaging/Controller/AttachmentController.php
@@ -7,7 +7,7 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Messaging\Model\Attachment;
use PhpList\Core\Domain\Messaging\Service\AttachmentDownloadService;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
diff --git a/src/Messaging/Controller/BounceController.php b/src/Messaging/Controller/BounceController.php
index 30587aa1..0deff115 100644
--- a/src/Messaging/Controller/BounceController.php
+++ b/src/Messaging/Controller/BounceController.php
@@ -8,8 +8,8 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Messaging\Model\Bounce;
use PhpList\Core\Domain\Messaging\Repository\BounceRepository;
-use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Messaging\Repository\Interfaces\UserMessageBounceReportReaderInterface;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
@@ -34,7 +34,7 @@ public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly BounceNormalizer $normalizer,
private readonly PaginatedDataProvider $paginatedProvider,
- private readonly UserMessageBounceRepository $userMessageBounceRepository
+ private readonly UserMessageBounceReportReaderInterface $userMessageBounceReportReader
) {
parent::__construct($authentication, $validator);
}
@@ -212,7 +212,7 @@ public function getBounceCountsByCampaign(Request $request): JsonResponse
$authUser = $this->requireAuthentication($request);
return $this->json(
- data: $this->userMessageBounceRepository->getCampaignBounceTotals($authUser->getId()),
+ data: $this->userMessageBounceReportReader->getCampaignBounceTotals($authUser->getId()),
status: Response::HTTP_OK
);
}
@@ -263,7 +263,7 @@ public function getBounceCountsBySubscriber(Request $request): JsonResponse
$authUser = $this->requireAuthentication($request);
return $this->json(
- data: $this->userMessageBounceRepository->getListBounceTotals($authUser->getId()),
+ data: $this->userMessageBounceReportReader->getListBounceTotals($authUser->getId()),
status: Response::HTTP_OK
);
}
diff --git a/src/Messaging/Controller/BounceRegexController.php b/src/Messaging/Controller/BounceRegexController.php
index 7cb09dba..f9e83ee3 100644
--- a/src/Messaging/Controller/BounceRegexController.php
+++ b/src/Messaging/Controller/BounceRegexController.php
@@ -8,7 +8,7 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Messaging\Model\BounceRegex;
use PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Messaging\Request\BounceRegexRequest;
diff --git a/src/Messaging/Controller/CampaignActionController.php b/src/Messaging/Controller/CampaignActionController.php
index 326f832d..06e16d4f 100644
--- a/src/Messaging/Controller/CampaignActionController.php
+++ b/src/Messaging/Controller/CampaignActionController.php
@@ -6,12 +6,13 @@
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
+use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage;
use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\SyncCampaignProcessorMessage;
use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\TestCampaignProcessorMessage;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Messaging\Request\Message\MessageMetadataRequest;
@@ -47,7 +48,7 @@ public function __construct(
parent::__construct($authentication, $validator);
}
- #[Route('/{messageId}/copy', name: 'copy_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])]
+ #[Route('/{messageId}/copy', name: 'copy', requirements: ['messageId' => '\d+'], methods: ['POST'])]
#[OA\Post(
path: '/api/v2/campaigns/{messageId}/copy',
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
@@ -103,6 +104,68 @@ public function copyMessage(
return $this->json($this->campaignService->getMessage($message), Response::HTTP_CREATED);
}
+ #[Route('/{messageId}/resume', name: 'resume', requirements: ['messageId' => '\d+'], methods: ['POST'])]
+ #[OA\Post(
+ path: '/api/v2/campaigns/{messageId}/resume',
+ description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
+ 'Resumes a campaign/message that is stuck in Prepared/InProcess status past the stuck-campaign ' .
+ 'threshold, re-dispatching it for processing.',
+ summary: 'Resumes a campaign stuck in processing.',
+ tags: ['campaigns'],
+ parameters: [
+ new OA\Parameter(
+ name: 'php-auth-pw',
+ description: 'Session key obtained from login',
+ in: 'header',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'messageId',
+ description: 'message ID',
+ in: 'path',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ )
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Success',
+ content: new OA\JsonContent(ref: '#/components/schemas/Message')
+ ),
+ new OA\Response(
+ response: 403,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
+ ),
+ new OA\Response(
+ response: 404,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse')
+ ),
+ new OA\Response(
+ response: 409,
+ description: 'Failure - campaign is not currently stuck in processing',
+ content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
+ ),
+ ]
+ )]
+ public function resumeStuckCampaign(
+ Request $request,
+ #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null
+ ): JsonResponse {
+ $authUser = $this->requireAuthentication($request);
+ if ($message === null) {
+ throw $this->createNotFoundException('Campaign not found.');
+ }
+
+ $this->campaignService->resumeStuckCampaign($authUser, $message);
+ $this->messageBus->dispatch(new CampaignProcessorMessage($message->getId()));
+
+ return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK);
+ }
+
#[Route('/{messageId}/status', name: 'update_status', requirements: ['messageId' => '\d+'], methods: ['PATCH'])]
#[OA\Patch(
path: '/api/v2/campaigns/{messageId}/status',
@@ -177,7 +240,7 @@ public function updateMessageStatus(
return $this->json($this->messageNormalizer->normalize($message), Response::HTTP_OK);
}
- #[Route('/{messageId}/send', name: 'send_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])]
+ #[Route('/{messageId}/send', name: 'send', requirements: ['messageId' => '\d+'], methods: ['POST'])]
#[OA\Post(
path: '/api/v2/campaigns/{messageId}/send',
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
@@ -232,7 +295,7 @@ public function sendMessage(
return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK);
}
- #[Route('/{messageId}/resend', name: 'resend_campaign', requirements: ['messageId' => '\d+'], methods: ['POST'])]
+ #[Route('/{messageId}/resend', name: 'resend', requirements: ['messageId' => '\d+'], methods: ['POST'])]
#[OA\Post(
path: '/api/v2/campaigns/{messageId}/resend',
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
@@ -299,7 +362,7 @@ public function resendMessageToLists(
#[Route(
'/{messageId}/test-send',
- name: 'test_send_campaign',
+ name: 'test_send',
requirements: ['messageId' => '\d+'],
methods: ['POST']
)]
diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php
index 1769aa7f..69d286ae 100644
--- a/src/Messaging/Controller/CampaignController.php
+++ b/src/Messaging/Controller/CampaignController.php
@@ -7,7 +7,7 @@
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Messaging\Model\Message;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Messaging\Request\CreateMessageRequest;
@@ -74,6 +74,21 @@ public function __construct(
required: false,
schema: new OA\Schema(type: 'string', maxLength: 50)
),
+ new OA\Parameter(
+ name: 'status',
+ description: 'Filter by one or more comma-separated statuses: ' .
+ 'draft, prepared, submitted, inprocess, sent, suspended',
+ in: 'query',
+ required: false,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'sort',
+ description: 'Sort direction by campaign id - desc returns newest campaigns first',
+ in: 'query',
+ required: false,
+ schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc'])
+ ),
],
responses: [
new OA\Response(
@@ -108,66 +123,11 @@ public function getMessages(Request $request): JsonResponse
);
}
- #[Route('/{messageId}', name: 'get_one', requirements: ['messageId' => '\d+'], methods: ['GET'])]
- #[OA\Get(
- path: '/api/v2/campaigns/{messageId}',
- description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
- 'Returns campaign/message by id.',
- summary: 'Gets a campaign by id.',
- tags: ['campaigns'],
- parameters: [
- new OA\Parameter(
- name: 'php-auth-pw',
- description: 'Session key obtained from login',
- in: 'header',
- required: true,
- schema: new OA\Schema(
- type: 'string'
- )
- ),
- new OA\Parameter(
- name: 'messageId',
- description: 'message ID',
- in: 'path',
- required: true,
- schema: new OA\Schema(type: 'string')
- )
- ],
- responses: [
- new OA\Response(
- response: 200,
- description: 'Success',
- content: new OA\JsonContent(ref: '#/components/schemas/Message')
- ),
- new OA\Response(
- response: 403,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
- ),
- new OA\Response(
- response: 404,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse')
- )
- ]
- )]
- public function getMessage(
- Request $request,
- #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null
- ): JsonResponse {
- $this->requireAuthentication($request);
- if ($message === null) {
- throw $this->createNotFoundException('Campaign not found.');
- }
-
- return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK);
- }
-
#[Route('', name: 'create', methods: ['POST'])]
#[OA\Post(
path: '/api/v2/campaigns',
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
- 'Returns created message.',
+ 'Returns created message.',
summary: 'Create a message for campaign.',
requestBody: new OA\RequestBody(
description: 'Create a new message.',
@@ -227,6 +187,114 @@ public function createMessage(Request $request): JsonResponse
return $this->json(data: $message, status: Response::HTTP_CREATED);
}
+ #[Route('/stuck', name: 'get_stuck', methods: ['GET'])]
+ #[OA\Get(
+ path: '/api/v2/campaigns/stuck',
+ description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
+ 'Returns campaigns stuck in Prepared/InProcess status past the stuck-campaign threshold, ' .
+ 'for admin review. No automatic action is taken on these campaigns.',
+ summary: 'Gets a list of campaigns stuck in processing.',
+ 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')
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Success',
+ content: new OA\JsonContent(
+ type: 'array',
+ items: new OA\Items(
+ properties: [
+ new OA\Property(property: 'id', type: 'integer'),
+ new OA\Property(property: 'subject', type: 'string'),
+ new OA\Property(property: 'status', type: 'string', example: 'inprocess'),
+ new OA\Property(
+ property: 'updated_at',
+ type: 'string',
+ format: 'date-time'
+ ),
+ new OA\Property(property: 'stuck_seconds', type: 'integer', example: 1845),
+ ],
+ type: 'object'
+ )
+ )
+ ),
+ new OA\Response(
+ response: 403,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
+ )
+ ]
+ )]
+ public function getStuckCampaigns(Request $request): JsonResponse
+ {
+ $this->requireAuthentication($request);
+
+ return $this->json($this->campaignService->getStuckCampaigns(), Response::HTTP_OK);
+ }
+
+ #[Route('/{messageId}', name: 'get_one', requirements: ['messageId' => '\d+'], methods: ['GET'])]
+ #[OA\Get(
+ path: '/api/v2/campaigns/{messageId}',
+ description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
+ 'Returns campaign/message by id.',
+ summary: 'Gets a campaign by id.',
+ tags: ['campaigns'],
+ parameters: [
+ new OA\Parameter(
+ name: 'php-auth-pw',
+ description: 'Session key obtained from login',
+ in: 'header',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string'
+ )
+ ),
+ new OA\Parameter(
+ name: 'messageId',
+ description: 'message ID',
+ in: 'path',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ )
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Success',
+ content: new OA\JsonContent(ref: '#/components/schemas/Message')
+ ),
+ new OA\Response(
+ response: 403,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
+ ),
+ new OA\Response(
+ response: 404,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse')
+ )
+ ]
+ )]
+ public function getMessage(
+ Request $request,
+ #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null
+ ): JsonResponse {
+ $this->requireAuthentication($request);
+ if ($message === null) {
+ throw $this->createNotFoundException('Campaign not found.');
+ }
+
+ return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK);
+ }
+
#[Route('/{messageId}', name: 'update', requirements: ['messageId' => '\d+'], methods: ['PUT'])]
#[OA\Put(
path: '/api/v2/campaigns/{messageId}',
diff --git a/src/Messaging/Controller/EditorUploadController.php b/src/Messaging/Controller/EditorUploadController.php
index adf70ab4..21b240e5 100644
--- a/src/Messaging/Controller/EditorUploadController.php
+++ b/src/Messaging/Controller/EditorUploadController.php
@@ -6,7 +6,7 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Common\Validator\UploadDirectoryValidator;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\Core\Domain\Common\Service\UploadService;
use PhpList\Core\Domain\Common\Service\DirectoryListingService;
diff --git a/src/Messaging/Controller/EmailForwardController.php b/src/Messaging/Controller/EmailForwardController.php
index c7e1210d..5147f04c 100644
--- a/src/Messaging/Controller/EmailForwardController.php
+++ b/src/Messaging/Controller/EmailForwardController.php
@@ -9,7 +9,7 @@
use PhpList\Core\Domain\Messaging\Model\Dto\MessageForwardDto;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Service\MessageForwardService;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Messaging\Request\ForwardMessageRequest;
diff --git a/src/Messaging/Controller/ListMessageController.php b/src/Messaging/Controller/ListMessageController.php
index fe0e80a7..191281b5 100644
--- a/src/Messaging/Controller/ListMessageController.php
+++ b/src/Messaging/Controller/ListMessageController.php
@@ -9,7 +9,7 @@
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Messaging\Serializer\ListMessageNormalizer;
diff --git a/src/Messaging/Controller/TemplateController.php b/src/Messaging/Controller/TemplateController.php
index 9c272566..9800cc45 100644
--- a/src/Messaging/Controller/TemplateController.php
+++ b/src/Messaging/Controller/TemplateController.php
@@ -9,7 +9,7 @@
use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter;
use PhpList\Core\Domain\Messaging\Model\Template;
use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
@@ -116,6 +116,68 @@ className: Template::class,
);
}
+ #[Route('', name: 'create', methods: ['POST'])]
+ #[OA\Post(
+ path: '/api/v2/templates',
+ description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
+ 'Returns a JSON response of created template.',
+ summary: 'Create a new template.',
+ requestBody: new OA\RequestBody(
+ description: 'Pass session credentials',
+ required: true,
+ content: new OA\MediaType(
+ mediaType: 'multipart/form-data',
+ schema: new OA\Schema(ref: '#/components/schemas/CreateTemplateRequest')
+ )
+ ),
+ tags: ['templates'],
+ 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: 201,
+ description: 'Success',
+ content: new OA\JsonContent(ref: '#/components/schemas/Template')
+ ),
+ new OA\Response(
+ response: 403,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
+ ),
+ new OA\Response(
+ response: 409,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/AlreadyExistsResponse')
+ ),
+ new OA\Response(
+ response: 422,
+ description: 'Failure',
+ content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse')
+ ),
+ ]
+ )]
+ public function createTemplates(Request $request): JsonResponse
+ {
+ $this->requireAuthentication($request);
+
+ /** @var CreateTemplateRequest $createTemplateRequest */
+ $createTemplateRequest = $this->validator->validate($request, CreateTemplateRequest::class);
+ $template = $this->templateManager->create($createTemplateRequest->getDto());
+ $this->entityManager->flush();
+
+ return $this->json(
+ $this->normalizer->normalize($template),
+ Response::HTTP_CREATED
+ );
+ }
+
#[Route('/defaults', name: 'get_defaults', methods: ['GET'])]
#[OA\Get(
path: '/api/v2/templates/defaults',
@@ -263,66 +325,6 @@ public function getTemplate(
return $this->json($this->normalizer->normalize($template), Response::HTTP_OK);
}
- #[Route('', name: 'create', methods: ['POST'])]
- #[OA\Post(
- path: '/api/v2/templates',
- description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
- 'Returns a JSON response of created template.',
- summary: 'Create a new template.',
- requestBody: new OA\RequestBody(
- description: 'Pass session credentials',
- required: true,
- content: new OA\MediaType(
- mediaType: 'multipart/form-data',
- schema: new OA\Schema(ref: '#/components/schemas/UpdateTemplateRequest')
- )
- ),
- tags: ['templates'],
- 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: 201,
- description: 'Success',
- content: new OA\JsonContent(
- type: 'array',
- items: new OA\Items(ref: '#/components/schemas/Template')
- )
- ),
- new OA\Response(
- response: 403,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
- ),
- new OA\Response(
- response: 422,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse')
- ),
- ]
- )]
- public function createTemplates(Request $request): JsonResponse
- {
- $this->requireAuthentication($request);
-
- /** @var CreateTemplateRequest $createTemplateRequest */
- $createTemplateRequest = $this->validator->validate($request, CreateTemplateRequest::class);
- $template = $this->templateManager->create($createTemplateRequest->getDto());
- $this->entityManager->flush();
-
- return $this->json(
- $this->normalizer->normalize($template),
- Response::HTTP_CREATED
- );
- }
-
#[Route('/{templateId}', name: 'update', methods: ['PUT'])]
#[OA\Put(
path: '/api/v2/templates/{templateId}',
diff --git a/src/Messaging/Request/BounceRegexRequest.php b/src/Messaging/Request/BounceRegexRequest.php
index 2f553305..9d625e7e 100644
--- a/src/Messaging/Request/BounceRegexRequest.php
+++ b/src/Messaging/Request/BounceRegexRequest.php
@@ -18,7 +18,13 @@
new OA\Property(property: 'action', type: 'string', example: 'delete', nullable: false),
new OA\Property(property: 'list_order', type: 'integer', example: 0, nullable: true),
new OA\Property(property: 'comment', type: 'string', example: 'Auto-generated', nullable: true),
- new OA\Property(property: 'status', type: 'string', example: 'active', nullable: false),
+ new OA\Property(
+ property: 'status',
+ type: 'string',
+ enum: ['active', 'inactive'],
+ example: 'active',
+ nullable: false
+ ),
],
type: 'object'
)]
@@ -39,7 +45,7 @@ class BounceRegexRequest implements RequestInterface
public ?string $comment = null;
#[Assert\Type('string')]
- #[Assert\Choice(['active', 'invite'])]
+ #[Assert\Choice(['active', 'inactive'])]
public string $status;
public function getDto(): array
diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php
index 2ae5ba3e..5e04fda5 100644
--- a/src/Messaging/Request/Message/MessageMetadataRequest.php
+++ b/src/Messaging/Request/Message/MessageMetadataRequest.php
@@ -17,7 +17,7 @@
new OA\Property(
property: 'status',
type: 'string',
- enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'],
+ enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended'],
example: 'draft'
),
],
@@ -26,11 +26,11 @@ enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'],
class MessageMetadataRequest implements RequestDtoInterface, RequestInterface
{
#[Assert\NotBlank]
- #[Assert\Choice(['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'])]
+ #[Assert\Choice(['draft', 'sent', 'prepared', 'submitted', 'suspended'])]
public string $status;
/**
- * @SuppressWarnings(PHPMD.StaticAccess)
+ * @SuppressWarnings("PHPMD.StaticAccess")
*/
public function getDto(): MessageMetadataDto
{
diff --git a/src/Messaging/Serializer/BounceNormalizer.php b/src/Messaging/Serializer/BounceNormalizer.php
index 09193d94..616c5514 100644
--- a/src/Messaging/Serializer/BounceNormalizer.php
+++ b/src/Messaging/Serializer/BounceNormalizer.php
@@ -26,7 +26,7 @@
class BounceNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -46,10 +46,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof BounceView;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ BounceView::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/BounceRegexNormalizer.php b/src/Messaging/Serializer/BounceRegexNormalizer.php
index 47d285fa..76b20dd1 100644
--- a/src/Messaging/Serializer/BounceRegexNormalizer.php
+++ b/src/Messaging/Serializer/BounceRegexNormalizer.php
@@ -26,7 +26,7 @@
class BounceRegexNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -48,10 +48,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof BounceRegex;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ BounceRegex::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/EditorUploadNormalizer.php b/src/Messaging/Serializer/EditorUploadNormalizer.php
index 4b1a4a36..8d744962 100644
--- a/src/Messaging/Serializer/EditorUploadNormalizer.php
+++ b/src/Messaging/Serializer/EditorUploadNormalizer.php
@@ -25,7 +25,7 @@
class EditorUploadNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -45,10 +45,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof UploadResult;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ UploadResult::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/FileListingNormalizer.php b/src/Messaging/Serializer/FileListingNormalizer.php
index cc7a2e52..6495c363 100644
--- a/src/Messaging/Serializer/FileListingNormalizer.php
+++ b/src/Messaging/Serializer/FileListingNormalizer.php
@@ -29,7 +29,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -40,7 +40,7 @@ public function normalize($object, string $format = null, array $context = []):
return [
'name' => $object->name,
'url' => $object->type === 'directory' ? null : $this->urlGenerator->generate(
- 'editor_uploads_get_file',
+ 'api_editor_uploads_get_file',
['filename' => $object->name],
UrlGeneratorInterface::ABSOLUTE_URL
),
@@ -51,10 +51,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof DirectoryEntryDto;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ DirectoryEntryDto::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/ForwardingResultNormalizer.php b/src/Messaging/Serializer/ForwardingResultNormalizer.php
index 61b85cf7..bdc7edc3 100644
--- a/src/Messaging/Serializer/ForwardingResultNormalizer.php
+++ b/src/Messaging/Serializer/ForwardingResultNormalizer.php
@@ -36,7 +36,7 @@
class ForwardingResultNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -62,10 +62,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof ForwardingResult;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ ForwardingResult::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/ListMessageNormalizer.php b/src/Messaging/Serializer/ListMessageNormalizer.php
index 5b9d1c2f..2c10d864 100644
--- a/src/Messaging/Serializer/ListMessageNormalizer.php
+++ b/src/Messaging/Serializer/ListMessageNormalizer.php
@@ -39,7 +39,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -57,10 +57,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof ListMessage;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ ListMessage::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php
index e636bb60..87e5f63e 100644
--- a/src/Messaging/Serializer/MessageNormalizer.php
+++ b/src/Messaging/Serializer/MessageNormalizer.php
@@ -91,7 +91,7 @@ public function __construct(private readonly TemplateNormalizer $templateNormali
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -145,10 +145,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Message;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ Message::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/TemplateImageNormalizer.php b/src/Messaging/Serializer/TemplateImageNormalizer.php
index 88206aa8..9b122579 100644
--- a/src/Messaging/Serializer/TemplateImageNormalizer.php
+++ b/src/Messaging/Serializer/TemplateImageNormalizer.php
@@ -32,7 +32,7 @@
class TemplateImageNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -52,10 +52,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof TemplateImage;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ TemplateImage::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Serializer/TemplateNormalizer.php b/src/Messaging/Serializer/TemplateNormalizer.php
index 3c75baa4..c9613d20 100644
--- a/src/Messaging/Serializer/TemplateNormalizer.php
+++ b/src/Messaging/Serializer/TemplateNormalizer.php
@@ -34,7 +34,7 @@ public function __construct(private readonly TemplateImageNormalizer $templateIm
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -55,10 +55,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Template;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ Template::class => true,
+ ];
+ }
}
diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php
index 5f50124b..0d8b7cfd 100644
--- a/src/Messaging/Service/CampaignService.php
+++ b/src/Messaging/Service/CampaignService.php
@@ -4,9 +4,11 @@
namespace PhpList\RestBundle\Messaging\Service;
+use DateTimeImmutable;
+use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Identity\Model\Administrator;
-use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
+use PhpList\Core\Domain\Identity\Service\PermissionChecker;
use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
@@ -14,8 +16,10 @@
use PhpList\RestBundle\Messaging\Request\CreateMessageRequest;
use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest;
use PhpList\RestBundle\Messaging\Serializer\MessageNormalizer;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CampaignService
@@ -25,6 +29,8 @@ public function __construct(
private readonly PaginatedDataProvider $paginatedProvider,
private readonly MessageNormalizer $normalizer,
private readonly EntityManagerInterface $entityManager,
+ private readonly PermissionChecker $permissionChecker,
+ #[Autowire('%messaging.stuck_campaign_threshold%')] private readonly int $stuckCampaignThresholdSeconds = 1800,
) {
}
@@ -32,7 +38,13 @@ public function getMessages(Request $request, Administrator $administrator): arr
{
$filter = (new MessageFilter())
->setOwner($administrator)
- ->setSubject($request->query->get('subject'));
+ ->setSubject($request->query->get('subject'))
+ ->setStatus($request->query->get('status'));
+
+ $sort = $request->query->get('sort');
+ if (in_array($sort, ['asc', 'desc'], true)) {
+ $filter->setSortOrder($sort);
+ }
return $this->paginatedProvider->getPaginatedList(
request: $request,
@@ -53,7 +65,7 @@ public function getMessage(Message $message = null): array
public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $administrator): array
{
- if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) {
+ if (!$this->permissionChecker->canCreate($administrator, Message::class)) {
throw new AccessDeniedHttpException('You are not allowed to create campaigns.');
}
@@ -70,13 +82,12 @@ public function updateMessage(
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.');
}
+ if (!$this->permissionChecker->canEdit($administrator, $message)) {
+ throw new AccessDeniedHttpException('You are not allowed to update campaigns.');
+ }
$data = $this->messageManager->updateMessage(
updateMessageDto: $updateMessageRequest->getDto(),
@@ -89,15 +100,64 @@ public function updateMessage(
public function deleteMessage(Administrator $administrator, Message $message = null): void
{
- if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) {
+ if (!$message) {
+ throw new NotFoundHttpException('Campaign not found.');
+ }
+ if (!$this->permissionChecker->canDelete($administrator, $message)) {
throw new AccessDeniedHttpException('You are not allowed to delete campaigns.');
}
+ $this->messageManager->delete($message);
+ $this->entityManager->flush();
+ }
+
+ /**
+ * Lists campaigns whose processing appears stalled: still in Prepared/InProcess status
+ * with no update for longer than the stuck-campaign threshold. This is a monitoring view
+ * only, no automatic action is taken - an admin decides whether to resume each one.
+ */
+ public function getStuckCampaigns(): array
+ {
+ $stuckMessages = $this->messageManager->getStuckCampaigns($this->getStaleBefore());
+ $now = new DateTimeImmutable();
+
+ return array_map(
+ fn (Message $message) => $this->toStuckCampaignArray($message, $now),
+ $stuckMessages
+ );
+ }
+
+ public function resumeStuckCampaign(Administrator $administrator, Message $message = null): void
+ {
if (!$message) {
throw new NotFoundHttpException('Campaign not found.');
}
+ if (!$this->permissionChecker->canEdit($administrator, $message)) {
+ throw new AccessDeniedHttpException('You are not allowed to update campaigns.');
+ }
- $this->messageManager->delete($message);
- $this->entityManager->flush();
+ $stuckIds = array_map(
+ static fn (Message $stuckMessage) => $stuckMessage->getId(),
+ $this->messageManager->getStuckCampaigns($this->getStaleBefore())
+ );
+ if (!in_array($message->getId(), $stuckIds, true)) {
+ throw new ConflictHttpException('Campaign is not currently stuck in processing.');
+ }
+ }
+
+ private function getStaleBefore(): DateTimeImmutable
+ {
+ return new DateTimeImmutable(sprintf('-%d seconds', $this->stuckCampaignThresholdSeconds));
+ }
+
+ private function toStuckCampaignArray(Message $message, DateTimeImmutable $now): array
+ {
+ return [
+ 'id' => $message->getId(),
+ 'subject' => $message->getContent()->getSubject(),
+ 'status' => $message->getMetadata()->getStatus()->value,
+ 'updated_at' => $message->getUpdatedAt()->format(DateTimeInterface::ATOM),
+ 'stuck_seconds' => $now->getTimestamp() - $message->getUpdatedAt()->getTimestamp(),
+ ];
}
}
diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php
index be8e9968..1760ef18 100644
--- a/src/Statistics/Controller/AnalyticsController.php
+++ b/src/Statistics/Controller/AnalyticsController.php
@@ -7,10 +7,11 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Analytics\Service\AnalyticsService;
use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer;
+use PhpList\RestBundle\Statistics\Serializer\DomainConfirmationNormalizer;
use PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer;
use PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer;
use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer;
@@ -18,7 +19,6 @@
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.
@@ -27,27 +27,18 @@
class AnalyticsController extends BaseController
{
public const BATCH_SIZE = 20;
- private AnalyticsService $analyticsService;
- private CampaignStatisticsNormalizer $campaignStatsNormalizer;
- private ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer;
- private TopDomainsNormalizer $topDomainsNormalizer;
- private TopLocalPartsNormalizer $topLocalPartsNormalizer;
public function __construct(
Authentication $authentication,
RequestValidator $validator,
- AnalyticsService $analyticsService,
- CampaignStatisticsNormalizer $campaignStatsNormalizer,
- ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer,
- TopDomainsNormalizer $topDomainsNormalizer,
- TopLocalPartsNormalizer $topLocalPartsNormalizer
+ private readonly AnalyticsService $analyticsService,
+ private readonly CampaignStatisticsNormalizer $campaignStatsNormalizer,
+ private readonly ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer,
+ private readonly TopDomainsNormalizer $topDomainsNormalizer,
+ private readonly TopLocalPartsNormalizer $topLocalPartsNormalizer,
+ private readonly DomainConfirmationNormalizer $domainConfirmationNormalizer,
) {
parent::__construct($authentication, $validator);
- $this->analyticsService = $analyticsService;
- $this->campaignStatsNormalizer = $campaignStatsNormalizer;
- $this->viewOpensStatsNormalizer = $viewOpensStatsNormalizer;
- $this->topDomainsNormalizer = $topDomainsNormalizer;
- $this->topLocalPartsNormalizer = $topLocalPartsNormalizer;
}
#[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])]
@@ -105,10 +96,7 @@ public function __construct(
)]
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.');
- }
+ $this->denyUnlessHasAccess($request);
$limit = (int) $request->query->get('limit', self::BATCH_SIZE);
$lastId = (int) $request->query->get('after_id', 0);
@@ -177,10 +165,7 @@ public function getCampaignStatistics(Request $request): JsonResponse
)]
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.');
- }
+ $this->denyUnlessHasAccess($request);
$limit = (int) $request->query->get('limit', self::BATCH_SIZE);
$lastId = (int) $request->query->get('after_id', 0);
@@ -239,10 +224,7 @@ public function getViewOpensStatistics(Request $request): JsonResponse
)]
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.');
- }
+ $this->denyUnlessHasAccess($request);
$limit = (int) $request->query->get('limit', self::BATCH_SIZE);
$minSubscribers = (int) $request->query->get('min_subscribers', 5);
@@ -293,16 +275,16 @@ public function getTopDomains(Request $request): JsonResponse
)]
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.');
- }
+ $this->denyUnlessHasAccess($request);
$limit = (int) $request->query->get('limit', 50);
-
$data = $this->analyticsService->getDomainConfirmationStatistics($limit);
- return $this->json($data, Response::HTTP_OK);
+ $normalizedData = $this->domainConfirmationNormalizer->normalize($data, null, [
+ 'domain_confirmation' => true,
+ ]);
+
+ return $this->json($normalizedData, Response::HTTP_OK);
}
#[Route('/local-parts/top', name: 'top_local_parts', methods: ['GET'])]
@@ -343,13 +325,9 @@ public function getDomainConfirmationStatistics(Request $request): JsonResponse
)]
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.');
- }
+ $this->denyUnlessHasAccess($request);
$limit = (int) $request->query->get('limit', 25);
-
$data = $this->analyticsService->getTopLocalParts($limit);
$normalizedData = $this->topLocalPartsNormalizer->normalize($data, null, [
'top_local_parts' => true,
@@ -358,12 +336,12 @@ public function getTopLocalParts(Request $request): JsonResponse
return $this->json($normalizedData, Response::HTTP_OK);
}
- #[Route('/dashboard', name: 'dashboard_statistics', methods: ['GET'])]
+ #[Route('/dashboard/summary', name: 'dashboard_summary', methods: ['GET'])]
#[OA\Get(
- path: '/api/v2/analytics/dashboard',
+ path: '/api/v2/analytics/dashboard/summary',
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
- 'Returns dashboard cards with aggregate analytics metrics.',
- summary: 'Gets dashboard analytics statistics.',
+ 'Returns dashboard summary statistics.',
+ summary: 'Gets dashboard summary statistics.',
tags: ['analytics'],
parameters: [
new OA\Parameter(
@@ -381,114 +359,66 @@ public function getTopLocalParts(Request $request): JsonResponse
content: new OA\JsonContent(
properties: [
new OA\Property(
- property: 'summary_statistics',
+ property: 'total_subscribers',
properties: [
+ new OA\Property(property: 'value', type: 'integer', example: 48294),
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'
+ 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: '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'
+ property: 'change_vs_last_month',
+ type: 'number',
+ format: 'float',
+ example: 0
),
+ ],
+ type: 'object'
+ ),
+ new OA\Property(
+ property: 'open_rate',
+ properties: [
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'
+ property: 'value',
+ type: 'number',
+ format: 'float',
+ example: 12
),
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'
+ property: 'change_vs_last_month',
+ type: 'number',
+ format: 'float',
+ example: 0
),
],
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'
- )
+ 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'
@@ -501,16 +431,138 @@ public function getTopLocalParts(Request $request): JsonResponse
)
]
)]
- public function getDashboardStatistics(Request $request): JsonResponse
+ public function getDashboardSummary(Request $request): JsonResponse
{
$this->requireAuthentication($request);
- $response = [
- 'summary_statistics' => $this->analyticsService->getSummaryStatistics(),
- 'recent_campaigns' => $this->analyticsService->getRecentCampaigns(),
- 'campaign_performance' => $this->analyticsService->getCampaignPerformance(),
- ];
+ $data = $this->analyticsService->getSummaryStatistics();
- return $this->json($response, Response::HTTP_OK);
+ return $this->json($data, Response::HTTP_OK);
+ }
+
+ #[Route('/dashboard/recent-campaigns', name: 'dashboard_recent_campaigns', methods: ['GET'])]
+ #[OA\Get(
+ path: '/api/v2/analytics/dashboard/recent-campaigns',
+ description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
+ 'Returns the most recent campaigns with their performance metrics.',
+ summary: 'Gets dashboard recent campaigns 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(
+ 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\Response(
+ response: 401,
+ description: 'Not authenticated',
+ content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
+ )
+ ]
+ )]
+ public function getRecentCampaignsStatistics(Request $request): JsonResponse
+ {
+ $this->requireAuthentication($request);
+
+ $data = $this->analyticsService->getRecentCampaigns();
+
+ return $this->json($data, Response::HTTP_OK);
+ }
+
+ #[Route('/dashboard/performance', name: 'dashboard_performance', methods: ['GET'])]
+ #[OA\Get(
+ path: '/api/v2/analytics/dashboard/performance',
+ description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' .
+ 'Returns campaign performance metrics over time.',
+ summary: 'Gets dashboard campaign performance 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(
+ 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'
+ )
+ )
+ ),
+ new OA\Response(
+ response: 401,
+ description: 'Not authenticated',
+ content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
+ )
+ ]
+ )]
+ public function getCampaignPerformanceStatistics(Request $request): JsonResponse
+ {
+ $this->requireAuthentication($request);
+
+ $data = $this->analyticsService->getCampaignPerformance();
+
+ return $this->json($data, Response::HTTP_OK);
+ }
+
+ private function denyUnlessHasAccess(Request $request): void
+ {
+ $admin = $this->requireAuthentication($request);
+ if ($admin->isSuperUser()) {
+ return;
+ }
+
+ if (!$admin->getPrivileges()->has(PrivilegeFlag::Statistics)) {
+ throw $this->createAccessDeniedException('You are not allowed to access statistics.');
+ }
}
}
diff --git a/src/Statistics/Controller/MessageOpenTrackController.php b/src/Statistics/Controller/MessageOpenTrackController.php
index ccd3a675..3873a864 100644
--- a/src/Statistics/Controller/MessageOpenTrackController.php
+++ b/src/Statistics/Controller/MessageOpenTrackController.php
@@ -8,7 +8,7 @@
use OpenApi\Attributes as OA;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\Core\Domain\Analytics\Service\UserMessageService;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
diff --git a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php
index b9f7e3d6..58454a18 100644
--- a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php
+++ b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php
@@ -27,7 +27,7 @@
class CampaignStatisticsNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize(mixed $object, string $format = null, array $context = []): array
{
@@ -71,7 +71,7 @@ private function normalizePagination(array $object, array $context): array
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
diff --git a/src/Statistics/Serializer/DomainConfirmationNormalizer.php b/src/Statistics/Serializer/DomainConfirmationNormalizer.php
new file mode 100644
index 00000000..3913ba63
--- /dev/null
+++ b/src/Statistics/Serializer/DomainConfirmationNormalizer.php
@@ -0,0 +1,96 @@
+ $domain['domain'],
+ 'confirmed' => [
+ 'count' => $domain['confirmed']['count'],
+ 'percentage' => $domain['confirmed']['percentage'],
+ ],
+ 'unconfirmed' => [
+ 'count' => $domain['unconfirmed']['count'],
+ 'percentage' => $domain['unconfirmed']['percentage'],
+ ],
+ 'blacklisted' => [
+ 'count' => $domain['blacklisted']['count'],
+ 'percentage' => $domain['blacklisted']['percentage'],
+ ],
+ 'total' => [
+ 'count' => $domain['total']['count'],
+ 'percentage' => $domain['total']['percentage'],
+ ]
+ ];
+ }
+
+ return [
+ 'items' => $domains,
+ 'total' => $object['total'],
+ ];
+ }
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
+ {
+ return is_array($data) && isset($context['domain_confirmation']);
+ }
+}
diff --git a/src/Statistics/Serializer/TopDomainsNormalizer.php b/src/Statistics/Serializer/TopDomainsNormalizer.php
index 06cde77f..a222d67f 100644
--- a/src/Statistics/Serializer/TopDomainsNormalizer.php
+++ b/src/Statistics/Serializer/TopDomainsNormalizer.php
@@ -11,7 +11,7 @@
schema: 'TopDomainStats',
properties: [
new OA\Property(
- property: 'domains',
+ property: 'items',
type: 'array',
items: new OA\Items(
properties: [
@@ -29,7 +29,7 @@
class TopDomainsNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize(mixed $object, string $format = null, array $context = []): array
{
@@ -46,13 +46,13 @@ public function normalize(mixed $object, string $format = null, array $context =
}
return [
- 'domains' => $domains,
+ 'items' => $domains,
'total' => $object['total'] ?? 0,
];
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
diff --git a/src/Statistics/Serializer/TopLocalPartsNormalizer.php b/src/Statistics/Serializer/TopLocalPartsNormalizer.php
index 6394a713..540b6e5f 100644
--- a/src/Statistics/Serializer/TopLocalPartsNormalizer.php
+++ b/src/Statistics/Serializer/TopLocalPartsNormalizer.php
@@ -11,7 +11,7 @@
schema: 'LocalPartsStats',
properties: [
new OA\Property(
- property: 'local_parts',
+ property: 'items',
type: 'array',
items: new OA\Items(
properties: [
@@ -30,7 +30,7 @@
class TopLocalPartsNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize(mixed $object, string $format = null, array $context = []): array
{
@@ -48,13 +48,13 @@ public function normalize(mixed $object, string $format = null, array $context =
}
return [
- 'local_parts' => $localParts,
+ 'items' => $localParts,
'total' => $object['total'] ?? 0,
];
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
diff --git a/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php
index 11cac28c..8db27575 100644
--- a/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php
+++ b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php
@@ -23,7 +23,7 @@
class ViewOpensStatisticsNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize(mixed $object, string $format = null, array $context = []): array
{
@@ -64,7 +64,7 @@ private function normalizePagination(array $object, array $context): array
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
diff --git a/src/Subscription/Controller/BlacklistController.php b/src/Subscription/Controller/BlacklistController.php
index 9b4e9580..96c674a3 100644
--- a/src/Subscription/Controller/BlacklistController.php
+++ b/src/Subscription/Controller/BlacklistController.php
@@ -8,7 +8,7 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Subscription\Request\AddToBlacklistRequest;
@@ -24,20 +24,14 @@
#[Route('/blacklist', name: 'blacklist_')]
class BlacklistController extends BaseController
{
- private SubscriberBlacklistManager $blacklistManager;
- private UserBlacklistNormalizer $normalizer;
-
public function __construct(
Authentication $authentication,
RequestValidator $validator,
- SubscriberBlacklistManager $blacklistManager,
- UserBlacklistNormalizer $normalizer,
+ private readonly SubscriberBlacklistManager $blacklistManager,
+ private readonly UserBlacklistNormalizer $normalizer,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct($authentication, $validator);
- $this->authentication = $authentication;
- $this->blacklistManager = $blacklistManager;
- $this->normalizer = $normalizer;
}
#[Route('/check/{email}', name: 'check', methods: ['GET'])]
@@ -82,10 +76,7 @@ public function __construct(
)]
public function checkEmailBlacklisted(Request $request, string $email): JsonResponse
{
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to check blacklisted emails.');
- }
+ $this->denyUnlessHasAccess($request);
$isBlacklisted = $this->blacklistManager->isEmailBlacklisted($email);
$reason = $isBlacklisted ? $this->blacklistManager->getBlacklistReason($email) : null;
@@ -146,10 +137,7 @@ public function checkEmailBlacklisted(Request $request, string $email): JsonResp
)]
public function addEmailToBlacklist(Request $request): JsonResponse
{
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to add emails to blacklist.');
- }
+ $this->denyUnlessHasAccess($request);
/** @var AddToBlacklistRequest $definitionRequest */
$definitionRequest = $this->validator->validate($request, AddToBlacklistRequest::class);
@@ -206,10 +194,7 @@ public function addEmailToBlacklist(Request $request): JsonResponse
)]
public function removeEmailFromBlacklist(Request $request, string $email): JsonResponse
{
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to remove emails from blacklist.');
- }
+ $this->denyUnlessHasAccess($request);
$this->blacklistManager->removeEmailFromBlacklist($email);
$this->entityManager->flush();
@@ -265,10 +250,7 @@ public function removeEmailFromBlacklist(Request $request, string $email): JsonR
)]
public function getBlacklistInfo(Request $request, string $email): JsonResponse
{
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to view blacklist information.');
- }
+ $this->denyUnlessHasAccess($request);
$blacklistInfo = $this->blacklistManager->getBlacklistInfo($email);
if (!$blacklistInfo) {
@@ -285,4 +267,16 @@ public function getBlacklistInfo(Request $request, string $email): JsonResponse
'reason' => $reason,
]);
}
+
+ private function denyUnlessHasAccess(Request $request): void
+ {
+ $admin = $this->requireAuthentication($request);
+ if ($admin->isSuperUser()) {
+ return;
+ }
+
+ if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
+ throw $this->createAccessDeniedException('You are not allowed to manage the blacklist.');
+ }
+ }
}
diff --git a/src/Subscription/Controller/ListMembersController.php b/src/Subscription/Controller/ListMembersController.php
index 2677da8c..c3eca51e 100644
--- a/src/Subscription/Controller/ListMembersController.php
+++ b/src/Subscription/Controller/ListMembersController.php
@@ -8,7 +8,7 @@
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php
index edfcac56..d8be3c27 100644
--- a/src/Subscription/Controller/SubscribePageController.php
+++ b/src/Subscription/Controller/SubscribePageController.php
@@ -7,10 +7,9 @@
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter;
-use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
use PhpList\Core\Domain\Subscription\Model\SubscribePage;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
@@ -36,7 +35,7 @@ public function __construct(
parent::__construct($authentication, $validator);
}
- #[Route('/', name: 'get_all', methods: ['GET'])]
+ #[Route('', name: 'get_all', methods: ['GET'])]
#[OA\Get(
path: '/api/v2/subscribe-pages',
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.',
@@ -81,11 +80,6 @@ public function __construct(
type: 'object'
)
),
- new OA\Response(
- response: 403,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
- ),
new OA\Response(
response: 404,
description: 'Not Found',
@@ -95,10 +89,7 @@ public function __construct(
)]
public function getPages(Request $request): JsonResponse
{
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.');
- }
+ $this->requireAuthentication($request);
return $this->json(
$this->paginatedProvider->getPaginatedList(
@@ -111,7 +102,7 @@ className: SubscribePage::class,
);
}
- #[Route('/', name: 'create', methods: ['POST'])]
+ #[Route('', name: 'create', methods: ['POST'])]
#[OA\Post(
path: '/api/v2/subscribe-pages',
description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.',
@@ -153,11 +144,6 @@ className: SubscribePage::class,
description: 'Created',
content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage')
),
- new OA\Response(
- response: 403,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
- ),
new OA\Response(
response: 422,
description: 'Validation failed',
@@ -168,9 +154,6 @@ className: SubscribePage::class,
public function createPage(Request $request): JsonResponse
{
$admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to create subscribe pages.');
- }
/** @var SubscribePageRequest $createRequest */
$createRequest = $this->validator->validate($request, SubscribePageRequest::class);
@@ -217,11 +200,6 @@ public function createPage(Request $request): JsonResponse
description: 'Success',
content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage'),
),
- new OA\Response(
- response: 403,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
- ),
new OA\Response(
response: 404,
description: 'Not Found',
@@ -231,10 +209,7 @@ public function createPage(Request $request): JsonResponse
)]
public function getPage(Request $request): JsonResponse
{
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.');
- }
+ $this->requireAuthentication($request);
$page = $this->subscribePageManager->findPage(id: (int) $request->get('id'));
if (!$page) {
@@ -293,11 +268,6 @@ public function getPage(Request $request): JsonResponse
description: 'Success',
content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage')
),
- new OA\Response(
- response: 403,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
- ),
new OA\Response(
response: 404,
description: 'Not Found',
@@ -310,9 +280,6 @@ public function updatePage(
#[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null
): JsonResponse {
$admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to update subscribe pages.');
- }
if (!$page) {
throw $this->createNotFoundException('Subscribe page not found');
@@ -359,11 +326,6 @@ public function updatePage(
],
responses: [
new OA\Response(response: 204, description: 'No Content'),
- new OA\Response(
- response: 403,
- description: 'Failure',
- content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse')
- ),
new OA\Response(
response: 404,
description: 'Not Found',
@@ -375,10 +337,7 @@ public function deletePage(
Request $request,
#[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null
): JsonResponse {
- $admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to delete subscribe pages.');
- }
+ $this->requireAuthentication($request);
if ($page === null) {
throw $this->createNotFoundException('Subscribe page not found');
diff --git a/src/Subscription/Controller/SubscribePagePublicController.php b/src/Subscription/Controller/SubscribePagePublicController.php
index 44b3284b..b851d87f 100644
--- a/src/Subscription/Controller/SubscribePagePublicController.php
+++ b/src/Subscription/Controller/SubscribePagePublicController.php
@@ -11,7 +11,7 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Subscription\Request\PublicSubscriptionRequest;
@@ -217,8 +217,8 @@ public function unsubscribe(Request $request, int $pageId): JsonResponse
return $this->json(null, Response::HTTP_NO_CONTENT);
}
- $listsIds = explode(',', $listsField->getData() ?? '');
- if ($listsIds == []) {
+ $listsIds = array_filter(explode(',', $listsField->getData() ?? ''));
+ if (count($listsIds) === 0) {
return $this->json(null, Response::HTTP_NO_CONTENT);
}
diff --git a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php
index 0851466a..92aa1d3b 100644
--- a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php
+++ b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php
@@ -10,7 +10,7 @@
use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
diff --git a/src/Subscription/Controller/SubscriberAttributeValueController.php b/src/Subscription/Controller/SubscriberAttributeValueController.php
index 463a7569..86f7123a 100644
--- a/src/Subscription/Controller/SubscriberAttributeValueController.php
+++ b/src/Subscription/Controller/SubscriberAttributeValueController.php
@@ -11,7 +11,7 @@
use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php
index e1acbe88..0c9eba2f 100644
--- a/src/Subscription/Controller/SubscriberController.php
+++ b/src/Subscription/Controller/SubscriberController.php
@@ -6,10 +6,10 @@
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
-use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
+use PhpList\Core\Domain\Identity\Service\PermissionChecker;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
@@ -42,6 +42,7 @@ public function __construct(
private readonly SubscriberHistoryService $subscriberHistoryService,
private readonly EntityManagerInterface $entityManager,
private readonly PaginatedDataProvider $paginatedDataProvider,
+ private readonly PermissionChecker $permissionChecker,
) {
parent::__construct($authentication, $validator);
$this->authentication = $authentication;
@@ -205,7 +206,7 @@ className: Subscriber::class,
public function createSubscriber(Request $request): JsonResponse
{
$admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
+ if (!$this->permissionChecker->canCreate($admin, Subscriber::class)) {
throw $this->createAccessDeniedException('You are not allowed to create subscribers.');
}
@@ -274,13 +275,12 @@ public function updateSubscriber(
#[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null,
): JsonResponse {
$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.');
}
+ if (!$this->permissionChecker->canEdit($admin, $subscriber)) {
+ throw $this->createAccessDeniedException('You are not allowed to update subscribers.');
+ }
/** @var UpdateSubscriberRequest $updateSubscriberRequest */
$updateSubscriberRequest = $this->validator->validate(
request: $request,
@@ -450,7 +450,6 @@ public function getSubscriberHistory(
);
}
-
#[Route('/{subscriberId}', name: 'delete', requirements: ['subscriberId' => '\d+'], methods: ['DELETE'])]
#[OA\Delete(
path: '/api/v2/subscribers/{subscriberId}',
@@ -496,13 +495,12 @@ public function deleteSubscriber(
#[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null,
): JsonResponse {
$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.');
}
+ if (!$this->permissionChecker->canDelete($admin, $subscriber)) {
+ throw $this->createAccessDeniedException('You are not allowed to delete subscribers.');
+ }
$this->subscriberManager->deleteSubscriber($subscriber);
$this->entityManager->flush();
@@ -564,13 +562,13 @@ public function resetBounceCount(
#[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null,
): Response {
$admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
- throw $this->createAccessDeniedException('You are not allowed to manage Subscribers.');
- }
if (!$subscriber) {
throw $this->createNotFoundException('Subscriber not found.');
}
+ if (!$this->permissionChecker->canEdit($admin, $subscriber)) {
+ throw $this->createAccessDeniedException('You are not allowed to edit Subscribers.');
+ }
$subscriber = $this->subscriberManager->resetBounceCount($subscriber);
$this->entityManager->flush();
@@ -615,7 +613,6 @@ public function resetBounceCount(
public function setSubscriberAsConfirmed(Request $request): Response
{
$uniqueId = $request->query->get('uniqueId');
-
if (!$uniqueId) {
return new Response('
Missing confirmation code.
', 400);
}
diff --git a/src/Subscription/Controller/SubscriberExportController.php b/src/Subscription/Controller/SubscriberExportController.php
index 223b7b32..f3f720ea 100644
--- a/src/Subscription/Controller/SubscriberExportController.php
+++ b/src/Subscription/Controller/SubscriberExportController.php
@@ -6,7 +6,7 @@
use OpenApi\Attributes as OA;
use PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Subscription\Request\SubscribersExportRequest;
diff --git a/src/Subscription/Controller/SubscriberImportController.php b/src/Subscription/Controller/SubscriberImportController.php
index 1e4983fc..2a99dd91 100644
--- a/src/Subscription/Controller/SubscriberImportController.php
+++ b/src/Subscription/Controller/SubscriberImportController.php
@@ -6,11 +6,12 @@
use Exception;
use OpenApi\Attributes as OA;
-use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
+use PhpList\Core\Domain\Identity\Service\PermissionChecker;
use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException;
use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -22,15 +23,13 @@
#[Route('/subscribers', name: 'subscriber_import_')]
class SubscriberImportController extends BaseController
{
- private SubscriberCsvImporter $importManager;
-
public function __construct(
Authentication $authentication,
RequestValidator $validator,
- SubscriberCsvImporter $importManager
+ private readonly SubscriberCsvImporter $importManager,
+ private readonly PermissionChecker $permissionChecker,
) {
parent::__construct($authentication, $validator);
- $this->importManager = $importManager;
}
#[Route('/import', name: 'csv', methods: ['POST'])]
@@ -115,7 +114,7 @@ enum: ['0', '1']
public function importSubscribers(Request $request): JsonResponse
{
$admin = $this->requireAuthentication($request);
- if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) {
+ if (!$this->permissionChecker->canCreate($admin, Subscriber::class)) {
throw $this->createAccessDeniedException('You are not allowed to create subscribers.');
}
diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php
index 44b6e5cf..46366df2 100644
--- a/src/Subscription/Controller/SubscriberListController.php
+++ b/src/Subscription/Controller/SubscriberListController.php
@@ -10,7 +10,7 @@
use PhpList\Core\Domain\Messaging\Model\Filter\SubscriberListFilter;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Common\Validator\RequestValidator;
diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php
index b67db97d..f5ff7488 100644
--- a/src/Subscription/Controller/SubscriptionController.php
+++ b/src/Subscription/Controller/SubscriptionController.php
@@ -9,7 +9,7 @@
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Subscription\Request\SubscriptionRequest;
@@ -154,7 +154,7 @@ public function createSubscription(
description: 'emails of subscribers to delete from list.',
in: 'query',
required: true,
- schema: new OA\Schema(type: 'string')
+ schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string', format: 'email'))
),
],
responses: [
diff --git a/src/Subscription/Serializer/AttributeDefinitionNormalizer.php b/src/Subscription/Serializer/AttributeDefinitionNormalizer.php
index 0dae549a..bf2cf6ec 100644
--- a/src/Subscription/Serializer/AttributeDefinitionNormalizer.php
+++ b/src/Subscription/Serializer/AttributeDefinitionNormalizer.php
@@ -38,7 +38,7 @@
class AttributeDefinitionNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -69,10 +69,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof SubscriberAttributeDefinition;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscriberAttributeDefinition::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscribePageDataNormalizer.php b/src/Subscription/Serializer/SubscribePageDataNormalizer.php
index eeef9f8f..9394105e 100644
--- a/src/Subscription/Serializer/SubscribePageDataNormalizer.php
+++ b/src/Subscription/Serializer/SubscribePageDataNormalizer.php
@@ -18,7 +18,7 @@
class SubscribePageDataNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -37,10 +37,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof SubscribePageData;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscribePageData::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscribePageNormalizer.php b/src/Subscription/Serializer/SubscribePageNormalizer.php
index 702b6484..e3edf716 100644
--- a/src/Subscription/Serializer/SubscribePageNormalizer.php
+++ b/src/Subscription/Serializer/SubscribePageNormalizer.php
@@ -32,7 +32,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -52,10 +52,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof SubscribePage;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscribePage::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php
index 25ab7c24..be88eefd 100644
--- a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php
+++ b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php
@@ -41,7 +41,14 @@
new OA\Property(
property: 'options',
type: 'array',
- items: new OA\Items(type: 'object')
+ items: new OA\Items(
+ properties: [
+ new OA\Property(property: 'id', type: 'integer'),
+ new OA\Property(property: 'name', type: 'string'),
+ new OA\Property(property: 'list_order', type: 'integer'),
+ ],
+ type: 'object'
+ )
),
],
type: 'object'
@@ -83,7 +90,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -128,7 +135,14 @@ private function getAttributeDefinitions(array $ids): array
'required' => $attributeDefinition->isRequired(),
'default_value' => $attributeDefinition->getDefaultValue(),
'list_order' => $attributeDefinition->getListOrder(),
- 'options' => $attributeDefinition->getOptions(),
+ 'options' => array_map(
+ static fn ($option) => [
+ 'id' => $option->id,
+ 'name' => $option->name,
+ 'list_order' => $option->listOrder,
+ ],
+ $attributeDefinition->getOptions()
+ ),
];
}
@@ -152,10 +166,20 @@ private function getLists(array $ids): array
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof SubscribePage;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscribePage::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscriberAttributeValueNormalizer.php b/src/Subscription/Serializer/SubscriberAttributeValueNormalizer.php
index 04a88e1c..12519d5e 100644
--- a/src/Subscription/Serializer/SubscriberAttributeValueNormalizer.php
+++ b/src/Subscription/Serializer/SubscriberAttributeValueNormalizer.php
@@ -25,7 +25,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -41,10 +41,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof SubscriberAttributeValue;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscriberAttributeValue::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscriberHistoryNormalizer.php b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php
index 9189a5e7..fd1cd1c5 100644
--- a/src/Subscription/Serializer/SubscriberHistoryNormalizer.php
+++ b/src/Subscription/Serializer/SubscriberHistoryNormalizer.php
@@ -4,8 +4,9 @@
namespace PhpList\RestBundle\Subscription\Serializer;
+use DateTimeInterface;
use OpenApi\Attributes as OA;
-use PhpList\Core\Domain\Subscription\Model\SubscriberHistory;
+use PhpList\Core\Domain\Subscription\Model\Interfaces\SubscriberHistoryRecordInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
#[OA\Schema(
@@ -28,18 +29,18 @@
class SubscriberHistoryNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
- if (!$object instanceof SubscriberHistory) {
+ if (!$object instanceof SubscriberHistoryRecordInterface) {
return [];
}
return [
'id' => $object->getId(),
'ip' => $object->getIp(),
- 'created_at' => $object->getCreatedAt()->format(\DateTimeInterface::ATOM),
+ 'created_at' => $object->getCreatedAt()->format(DateTimeInterface::ATOM),
'summary' => $object->getSummary(),
'detail' => $object->getDetail(),
'system_info' => $object->getSystemInfo(),
@@ -47,10 +48,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
- return $data instanceof SubscriberHistory;
+ return $data instanceof SubscriberHistoryRecordInterface;
+ }
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscriberHistoryRecordInterface::class => true,
+ ];
}
}
diff --git a/src/Subscription/Serializer/SubscriberListNormalizer.php b/src/Subscription/Serializer/SubscriberListNormalizer.php
index 976fcf2a..394d484f 100644
--- a/src/Subscription/Serializer/SubscriberListNormalizer.php
+++ b/src/Subscription/Serializer/SubscriberListNormalizer.php
@@ -31,7 +31,7 @@
class SubscriberListNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -53,10 +53,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof SubscriberList;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscriberList::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscriberNormalizer.php b/src/Subscription/Serializer/SubscriberNormalizer.php
index 9ce81556..4ab48307 100644
--- a/src/Subscription/Serializer/SubscriberNormalizer.php
+++ b/src/Subscription/Serializer/SubscriberNormalizer.php
@@ -4,9 +4,10 @@
namespace PhpList\RestBundle\Subscription\Serializer;
+use DateTimeInterface;
use OpenApi\Attributes as OA;
+use PhpList\Core\Domain\Subscription\Model\Interfaces\SubscriberHistoryRecordInterface;
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;
@@ -56,7 +57,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -67,8 +68,8 @@ public function normalize($object, string $format = null, array $context = []):
return [
'id' => $object->getId(),
'email' => $object->getEmail(),
- 'created_at' => $object->getCreatedAt()?->format(\DateTimeInterface::ATOM),
- 'updated_at' => $object->getUpdatedAt()?->format(\DateTimeInterface::ATOM),
+ 'created_at' => $object->getCreatedAt()->format(DateTimeInterface::ATOM),
+ 'updated_at' => $object->getUpdatedAt()->format(DateTimeInterface::ATOM),
'confirmed' => $object->isConfirmed(),
'blacklisted' => $object->isBlacklisted(),
'bounce_count' => $object->getBounceCount(),
@@ -79,17 +80,27 @@ public function normalize($object, string $format = null, array $context = []):
'subscribed_lists' => array_map(function (Subscription $subscription) {
return $this->subscriberListNormalizer->normalize($subscription->getSubscriberList());
}, $object->getSubscriptions()->toArray()),
- 'history' => array_map(function (SubscriberHistory $history) {
+ 'history' => array_map(function (SubscriberHistoryRecordInterface $history) {
return $this->subscriberHistoryNormalizer->normalize($history);
}, $object->getHistory()),
];
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Subscriber;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ Subscriber::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscriberOnlyNormalizer.php b/src/Subscription/Serializer/SubscriberOnlyNormalizer.php
index 0b93d8af..752384c8 100644
--- a/src/Subscription/Serializer/SubscriberOnlyNormalizer.php
+++ b/src/Subscription/Serializer/SubscriberOnlyNormalizer.php
@@ -31,7 +31,7 @@
class SubscriberOnlyNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -53,10 +53,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Subscriber;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ Subscriber::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscribersExportRequestNormalizer.php b/src/Subscription/Serializer/SubscribersExportRequestNormalizer.php
index 122ee800..e3875e32 100644
--- a/src/Subscription/Serializer/SubscribersExportRequestNormalizer.php
+++ b/src/Subscription/Serializer/SubscribersExportRequestNormalizer.php
@@ -10,7 +10,7 @@
class SubscribersExportRequestNormalizer implements NormalizerInterface
{
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -28,10 +28,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof SubscribersExportRequest;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ SubscribersExportRequest::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/SubscriptionNormalizer.php b/src/Subscription/Serializer/SubscriptionNormalizer.php
index eac2fef8..b7ed3d01 100644
--- a/src/Subscription/Serializer/SubscriptionNormalizer.php
+++ b/src/Subscription/Serializer/SubscriptionNormalizer.php
@@ -37,7 +37,7 @@ public function __construct(
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -53,10 +53,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Subscription;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ Subscription::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Serializer/UserBlacklistNormalizer.php b/src/Subscription/Serializer/UserBlacklistNormalizer.php
index 466debe7..14e839b1 100644
--- a/src/Subscription/Serializer/UserBlacklistNormalizer.php
+++ b/src/Subscription/Serializer/UserBlacklistNormalizer.php
@@ -15,7 +15,7 @@ public function __construct(private readonly SubscriberBlacklistManager $blackli
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function normalize($object, string $format = null, array $context = []): array
{
@@ -33,10 +33,20 @@ public function normalize($object, string $format = null, array $context = []):
}
/**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
*/
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof UserBlacklist;
}
+
+ /**
+ * @SuppressWarnings("PHPMD.UnusedFormalParameter")
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ UserBlacklist::class => true,
+ ];
+ }
}
diff --git a/src/Subscription/Validator/Constraint/UniqueEmailValidator.php b/src/Subscription/Validator/Constraint/UniqueEmailValidator.php
index 46bf52b7..ad421324 100644
--- a/src/Subscription/Validator/Constraint/UniqueEmailValidator.php
+++ b/src/Subscription/Validator/Constraint/UniqueEmailValidator.php
@@ -5,7 +5,6 @@
namespace PhpList\RestBundle\Subscription\Validator\Constraint;
use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -39,7 +38,8 @@ public function validate($value, Constraint $constraint): void
$updatingId = $dto->subscriberId ?? null;
if ($existingUser && $existingUser->getId() !== $updatingId) {
- throw new ConflictHttpException('Email already exists.');
+ $this->context->buildViolation($constraint->message)
+ ->addViolation();
}
}
}
diff --git a/tests/Integration/Common/AbstractTestController.php b/tests/Integration/Common/AbstractTestController.php
index e8d96b85..939f2bc9 100644
--- a/tests/Integration/Common/AbstractTestController.php
+++ b/tests/Integration/Common/AbstractTestController.php
@@ -147,7 +147,7 @@ protected function assertHttpStatusWithJsonContentType(int $status): void
{
$response = self::getClient()->getResponse();
- self::assertSame($status, $response->getStatusCode());
+ self::assertSame($status, $response->getStatusCode(), (string) $response->getContent());
self::assertStringContainsString('application/json', (string)$response->headers);
}
diff --git a/tests/Integration/Common/Routing/RoutingTest.php b/tests/Integration/Common/Routing/RoutingTest.php
index cfbe3cc3..bbab5c77 100644
--- a/tests/Integration/Common/Routing/RoutingTest.php
+++ b/tests/Integration/Common/Routing/RoutingTest.php
@@ -16,7 +16,7 @@ class RoutingTest extends WebTestCase
public function testRootUrlHasHtmlContentType()
{
$client = self::createClient();
- $client->request('get', '/api/v2');
+ $client->request('GET', '/api/v2', server: ['HTTP_ACCEPT' => 'text/html']);
$response = $client->getResponse();
diff --git a/tests/Integration/Composer/ScriptsTest.php b/tests/Integration/Composer/ScriptsTest.php
index 5b0df800..2cd0691b 100644
--- a/tests/Integration/Composer/ScriptsTest.php
+++ b/tests/Integration/Composer/ScriptsTest.php
@@ -29,9 +29,7 @@ private function getAbsolutePublicDirectoryPath(): string
public static function publicDirectoryFilesDataProvider(): array
{
return [
- 'production entry point' => ['app.php'],
- 'development entry point' => ['app_dev.php'],
- 'testing entry point' => ['app_test.php'],
+ 'entry point' => ['index.php'],
'.htaccess' => ['.htaccess'],
];
}
diff --git a/tests/Integration/Configuration/Controller/ConfigControllerTest.php b/tests/Integration/Configuration/Controller/ConfigControllerTest.php
index 4fe00e72..55829098 100644
--- a/tests/Integration/Configuration/Controller/ConfigControllerTest.php
+++ b/tests/Integration/Configuration/Controller/ConfigControllerTest.php
@@ -44,7 +44,6 @@ public function testListWithValidSessionKeyReturnsOkayWithPaginationStructure():
$this->assertHttpOkay();
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
self::assertArrayHasKey('items', $response);
self::assertArrayHasKey('pagination', $response);
}
diff --git a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php
index 53f2cd77..0ff8b5fd 100644
--- a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php
+++ b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php
@@ -99,6 +99,6 @@ public function testResetPasswordWithShortPasswordReturnsError422(): void
$this->assertHttpUnprocessableEntity();
$data = $this->getDecodedJsonResponseContent();
$this->assertStringContainsString('Validation failed', $data['message']);
- $this->assertStringContainsString('This value is too short', $data['errors']['newPassword'][0]);
+ $this->assertStringContainsString('This value is too short', $data['errors']['new_password'][0]);
}
}
diff --git a/tests/Integration/Identity/Controller/SessionControllerTest.php b/tests/Integration/Identity/Controller/SessionControllerTest.php
index 89479e0f..aeac5d0b 100644
--- a/tests/Integration/Identity/Controller/SessionControllerTest.php
+++ b/tests/Integration/Identity/Controller/SessionControllerTest.php
@@ -67,7 +67,7 @@ public function testPostSessionsWithValidEmptyJsonWithOtherTypeReturnsError422()
[
'message' => 'Validation failed',
'errors' => [
- 'loginName' => [
+ 'login_name' => [
'This value should not be blank.',
],
'password' => [
@@ -149,7 +149,7 @@ public function testPostSessionsActionWithValidCredentialsCreatesToken()
$key = $responseContent['key'];
$expiry = $responseContent['expiry_date'];
- /** @var AdministratorToken $token */
+ /** @var ?AdministratorToken $token */
$token = $this->administratorTokenRepository->find($tokenId);
self::assertNotNull($token);
diff --git a/tests/Integration/Messaging/Controller/BounceControllerTest.php b/tests/Integration/Messaging/Controller/BounceControllerTest.php
index 62fd9632..0787188f 100644
--- a/tests/Integration/Messaging/Controller/BounceControllerTest.php
+++ b/tests/Integration/Messaging/Controller/BounceControllerTest.php
@@ -45,7 +45,6 @@ public function testListWithValidSessionKeyReturnsOkayWithPaginationStructure():
$this->assertHttpOkay();
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
self::assertArrayHasKey('items', $response);
self::assertArrayHasKey('pagination', $response);
self::assertIsArray($response['items']);
@@ -111,4 +110,32 @@ public function testDeleteWithValidIdReturnsNoContentAndRemovesBounce(): void
$this->entityManager->clear();
self::assertNull($this->entityManager->getRepository(Bounce::class)->find($bounceId));
}
+
+ public function testGetBounceCountsByCampaignWithoutSessionKeyReturnsUnauthorized(): void
+ {
+ self::getClient()->request('GET', '/api/v2/bounces/by/campaign');
+ $this->assertHttpUnauthorized();
+ }
+
+ public function testGetBounceCountsByCampaignWithValidSessionKeyReturnsArray(): void
+ {
+ // Regression check for wiring BounceController to UserMessageBounceReportReaderInterface
+ // (Elasticsearch-backed by default) - exercises the real service graph end to end.
+ $this->authenticatedJsonRequest('GET', '/api/v2/bounces/by/campaign');
+ $this->assertHttpOkay();
+ }
+
+ public function testGetBounceCountsBySubscriberWithoutSessionKeyReturnsUnauthorized(): void
+ {
+ self::getClient()->request('GET', '/api/v2/bounces/by/subscriber');
+ $this->assertHttpUnauthorized();
+ }
+
+ public function testGetBounceCountsBySubscriberWithValidSessionKeyReturnsArray(): void
+ {
+ // Regression check for wiring BounceController to UserMessageBounceReportReaderInterface
+ // (Elasticsearch-backed by default) - exercises the real service graph end to end.
+ $this->authenticatedJsonRequest('GET', '/api/v2/bounces/by/subscriber');
+ $this->assertHttpOkay();
+ }
}
diff --git a/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php b/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php
index 61c8f61c..4860554f 100644
--- a/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php
+++ b/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php
@@ -69,7 +69,6 @@ public function testCreateGetDeleteFlow(): void
$this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex');
$this->assertHttpOkay();
$list = $this->getDecodedJsonResponseContent();
- $this->assertIsArray($list);
$this->assertIsArray($list[0] ?? []);
$this->authenticatedJsonRequest('DELETE', '/api/v2/bounces/regex/' . $id);
diff --git a/tests/Integration/Messaging/Controller/CampaignControllerTest.php b/tests/Integration/Messaging/Controller/CampaignControllerTest.php
index 0cadb2d6..ea176e25 100644
--- a/tests/Integration/Messaging/Controller/CampaignControllerTest.php
+++ b/tests/Integration/Messaging/Controller/CampaignControllerTest.php
@@ -51,7 +51,6 @@ public function testGetCampaignsReturnsCampaignData(): void
$this->authenticatedJsonRequest('GET', '/api/v2/campaigns');
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
self::assertArrayHasKey('id', $response['items'][0]);
self::assertArrayHasKey('message_content', $response['items'][0]);
}
@@ -87,4 +86,48 @@ public function testDeleteCampaignReturnsNoContent(): void
$this->authenticatedJsonRequest('DELETE', '/api/v2/campaigns/1');
$this->assertHttpNoContent();
}
+
+ public function testGetCampaignsFiltersBySingleStatus(): void
+ {
+ $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]);
+
+ $this->authenticatedJsonRequest('GET', '/api/v2/campaigns?status=sent');
+ $response = $this->getDecodedJsonResponseContent();
+
+ self::assertCount(1, $response['items']);
+ self::assertSame(1, $response['items'][0]['id']);
+ }
+
+ public function testGetCampaignsFiltersByCommaSeparatedStatuses(): void
+ {
+ $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]);
+
+ $this->authenticatedJsonRequest('GET', '/api/v2/campaigns?status=submitted,draft');
+ $response = $this->getDecodedJsonResponseContent();
+
+ self::assertCount(1, $response['items']);
+ self::assertSame(2, $response['items'][0]['id']);
+ }
+
+ public function testGetCampaignsSortsDescendingWhenRequested(): void
+ {
+ $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]);
+
+ $this->authenticatedJsonRequest('GET', '/api/v2/campaigns?sort=desc');
+ $response = $this->getDecodedJsonResponseContent();
+
+ self::assertSame(2, $response['items'][0]['id']);
+ self::assertSame(1, $response['items'][1]['id']);
+ }
+
+ public function testGetCampaignsDefaultsToAscendingOrder(): void
+ {
+ $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]);
+
+ $this->authenticatedJsonRequest('GET', '/api/v2/campaigns');
+ $response = $this->getDecodedJsonResponseContent();
+
+ self::assertSame(1, $response['items'][0]['id']);
+ self::assertSame(2, $response['items'][1]['id']);
+ }
}
diff --git a/tests/Integration/Messaging/Controller/EditorUploadControllerTest.php b/tests/Integration/Messaging/Controller/EditorUploadControllerTest.php
index 94bdfd86..6bebb690 100644
--- a/tests/Integration/Messaging/Controller/EditorUploadControllerTest.php
+++ b/tests/Integration/Messaging/Controller/EditorUploadControllerTest.php
@@ -38,7 +38,6 @@ public function testListFilesReturnsCorrectStructure(): void
$this->assertHttpOkay();
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
self::assertArrayHasKey('files', $response);
self::assertArrayHasKey('directory', $response);
self::assertArrayHasKey('total', $response);
diff --git a/tests/Integration/Messaging/Controller/EmailForwardControllerTest.php b/tests/Integration/Messaging/Controller/EmailForwardControllerTest.php
index e3f1ac51..920448e2 100644
--- a/tests/Integration/Messaging/Controller/EmailForwardControllerTest.php
+++ b/tests/Integration/Messaging/Controller/EmailForwardControllerTest.php
@@ -50,7 +50,6 @@ public function testForwardWithValidDataButNotReceivedEmail(): void
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']);
}
diff --git a/tests/Integration/Messaging/Controller/TemplateControllerTest.php b/tests/Integration/Messaging/Controller/TemplateControllerTest.php
index 69e6d401..81b6fe1c 100644
--- a/tests/Integration/Messaging/Controller/TemplateControllerTest.php
+++ b/tests/Integration/Messaging/Controller/TemplateControllerTest.php
@@ -52,7 +52,6 @@ public function testGetTemplatesReturnsTemplateData(): void
$this->authenticatedJsonRequest('GET', '/api/v2/templates');
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
self::assertArrayHasKey('id', $response['items'][0]);
self::assertArrayHasKey('title', $response['items'][0]);
}
@@ -68,7 +67,6 @@ public function testCreateTemplateFromDefaultWithValidSessionKeyReturnsCreated()
$this->authenticatedJsonRequest('GET', '/api/v2/templates/defaults');
$defaults = $this->getDecodedJsonResponseContent();
- self::assertIsArray($defaults);
self::assertNotEmpty($defaults);
self::assertArrayHasKey('key', $defaults[0]);
diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php
index 18ee8c19..86a263a6 100644
--- a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php
+++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php
@@ -54,7 +54,6 @@ public function testGetCampaignStatisticsReturnsCampaignData(): void
$this->authenticatedJsonRequest('GET', '/api/v2/analytics/campaigns');
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
self::assertArrayHasKey('items', $response);
self::assertArrayHasKey('pagination', $response);
}
@@ -80,7 +79,6 @@ public function testGetViewOpensStatisticsReturnsViewData(): void
$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']);
@@ -108,10 +106,9 @@ public function testGetTopDomainsReturnsDomainsData(): void
$this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top');
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
- self::assertArrayHasKey('domains', $response);
+ self::assertArrayHasKey('items', $response);
self::assertArrayHasKey('total', $response);
- self::assertIsArray($response['domains']);
+ self::assertIsArray($response['items']);
self::assertIsInt($response['total']);
}
@@ -122,10 +119,9 @@ public function testGetTopDomainsWithLimitParameter(): void
$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']));
+ self::assertArrayHasKey('items', $response);
+ self::assertIsArray($response['items']);
+ self::assertLessThanOrEqual(5, count($response['items']));
}
public function testGetTopDomainsWithMinSubscribersParameter(): void
@@ -135,12 +131,11 @@ public function testGetTopDomainsWithMinSubscribersParameter(): void
$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']);
+ self::assertArrayHasKey('items', $response);
+ self::assertIsArray($response['items']);
// Verify all domains have at least 10 subscribers
- foreach ($response['domains'] as $domain) {
+ foreach ($response['items'] as $domain) {
self::assertArrayHasKey('subscribers', $domain);
self::assertGreaterThanOrEqual(10, $domain['subscribers']);
}
@@ -153,12 +148,11 @@ public function testGetTopDomainsWithBothParameters(): void
$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']));
+ self::assertArrayHasKey('items', $response);
+ self::assertIsArray($response['items']);
+ self::assertLessThanOrEqual(3, count($response['items']));
- foreach ($response['domains'] as $domain) {
+ foreach ($response['items'] as $domain) {
self::assertArrayHasKey('subscribers', $domain);
self::assertGreaterThanOrEqual(10, $domain['subscribers']);
}
@@ -171,9 +165,8 @@ public function testGetTopDomainsWithInvalidLimitParameter(): void
$this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?limit=invalid');
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
- self::assertArrayHasKey('domains', $response);
- self::assertIsArray($response['domains']);
+ self::assertArrayHasKey('items', $response);
+ self::assertIsArray($response['items']);
}
public function testGetDomainConfirmationStatisticsWithoutSessionKeyReturnsUnauthorized(): void
@@ -197,8 +190,7 @@ public function testGetDomainConfirmationStatisticsReturnsConfirmationData(): vo
$this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/confirmation');
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
- self::assertArrayHasKey('domains', $response);
+ self::assertArrayHasKey('items', $response);
self::assertArrayHasKey('total', $response);
}
@@ -223,10 +215,9 @@ public function testGetTopLocalPartsReturnsLocalPartsData(): void
$this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top');
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
- self::assertArrayHasKey('local_parts', $response);
+ self::assertArrayHasKey('items', $response);
self::assertArrayHasKey('total', $response);
- self::assertIsArray($response['local_parts']);
+ self::assertIsArray($response['items']);
self::assertIsInt($response['total']);
}
@@ -237,10 +228,9 @@ public function testGetTopLocalPartsWithLimitParameter(): void
$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']));
+ self::assertArrayHasKey('items', $response);
+ self::assertIsArray($response['items']);
+ self::assertLessThanOrEqual(5, count($response['items']));
}
public function testGetTopLocalPartsWithInvalidLimitParameter(): void
@@ -250,18 +240,17 @@ public function testGetTopLocalPartsWithInvalidLimitParameter(): void
$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']);
+ self::assertArrayHasKey('items', $response);
+ self::assertIsArray($response['items']);
}
- public function testGetDashboardStatisticsWithoutSessionKeyReturnsUnauthorized(): void
+ public function testGetDashboardSummaryWithoutSessionKeyReturnsUnauthorized(): void
{
- self::getClient()->request('GET', '/api/v2/analytics/dashboard');
+ self::getClient()->request('GET', '/api/v2/analytics/dashboard/summary');
$this->assertHttpUnauthorized();
}
- public function testGetDashboardStatisticsWithValidSessionReturnsCardsData(): void
+ public function testGetDashboardSummaryWithValidSessionReturnsCardsData(): void
{
$this->loadFixtures([
AdministratorFixture::class,
@@ -270,21 +259,56 @@ public function testGetDashboardStatisticsWithValidSessionReturnsCardsData(): vo
MessageFixture::class,
]);
- $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard');
+ $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard/summary');
$this->assertHttpOkay();
$response = $this->getDecodedJsonResponseContent();
- self::assertIsArray($response);
- self::assertArrayHasKey('summary_statistics', $response);
- self::assertArrayHasKey('recent_campaigns', $response);
- self::assertArrayHasKey('campaign_performance', $response);
-
foreach (['total_subscribers', 'active_campaigns', 'open_rate', 'bounce_rate'] as $metric) {
- self::assertIsArray($response['summary_statistics'][$metric]);
- self::assertArrayHasKey('value', $response['summary_statistics'][$metric]);
- self::assertArrayHasKey('change_vs_last_month', $response['summary_statistics'][$metric]);
- self::assertIsNumeric($response['summary_statistics'][$metric]['value']);
- self::assertIsNumeric($response['summary_statistics'][$metric]['change_vs_last_month']);
+ 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']);
}
}
+
+ public function testGetRecentCampaignsStatisticsWithoutSessionKeyReturnsUnauthorized(): void
+ {
+ self::getClient()->request('GET', '/api/v2/analytics/dashboard/recent-campaigns');
+ $this->assertHttpUnauthorized();
+ }
+
+ public function testGetRecentCampaignsStatisticsWithValidSessionReturnsCampaignsData(): void
+ {
+ $this->loadFixtures([
+ AdministratorFixture::class,
+ AdministratorTokenFixture::class,
+ SubscriberFixture::class,
+ MessageFixture::class,
+ ]);
+
+ $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard/recent-campaigns');
+ $this->assertHttpOkay();
+ $response = $this->getDecodedJsonResponseContent();
+ }
+
+ public function testGetCampaignPerformanceStatisticsWithoutSessionKeyReturnsUnauthorized(): void
+ {
+ self::getClient()->request('GET', '/api/v2/analytics/dashboard/performance');
+ $this->assertHttpUnauthorized();
+ }
+
+ public function testGetCampaignPerformanceStatisticsWithValidSessionReturnsPerformanceData(): void
+ {
+ $this->loadFixtures([
+ AdministratorFixture::class,
+ AdministratorTokenFixture::class,
+ SubscriberFixture::class,
+ MessageFixture::class,
+ ]);
+
+ $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard/performance');
+ $this->assertHttpOkay();
+ $this->getDecodedJsonResponseContent();
+ }
}
diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php
index 8cfba268..24d3d634 100644
--- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php
+++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php
@@ -75,7 +75,7 @@ public function testCreateSubscribePageWithoutSessionReturnsUnauthorized(): void
],
], JSON_THROW_ON_ERROR);
- $this->jsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload);
+ $this->jsonRequest('POST', '/api/v2/subscribe-pages', content: $payload);
$this->assertHttpUnauthorized();
}
@@ -91,7 +91,7 @@ public function testCreateSubscribePageWithSessionCreatesPage(): void
],
], JSON_THROW_ON_ERROR);
- $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload);
+ $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload);
$this->assertHttpCreated();
$data = $this->getDecodedJsonResponseContent();
@@ -138,7 +138,7 @@ public function testCreateSubscribePageWithDataMissingValueReturnsUnprocessableE
],
], JSON_THROW_ON_ERROR);
- $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload);
+ $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload);
$this->assertHttpUnprocessableEntity();
}
diff --git a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php
index 1079f22c..ba11ac3b 100644
--- a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php
+++ b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php
@@ -81,7 +81,7 @@ public function testPostSubscribersWithValidSessionKeyAndValidDataCreatesSubscri
self::assertInstanceOf(Subscriber::class, $this->subscriberRepository->find($subscriberId));
}
- public function testPostSubscribersWithValidSessionKeyAndExistingEmailAddressCreatesConflictStatus()
+ public function testPostSubscribersWithValidSessionKeyAndExistingEmailAddressReturnsValidationError()
{
$this->loadFixtures([SubscriberFixture::class]);
@@ -90,7 +90,9 @@ public function testPostSubscribersWithValidSessionKeyAndExistingEmailAddressCre
$this->authenticatedJsonRequest('post', '/api/v2/subscribers', [], [], [], json_encode($jsonData));
- $this->assertHttpConflict();
+ $this->assertHttpUnprocessableEntity();
+ $data = $this->getDecodedJsonResponseContent();
+ $this->assertSame(['This email is already in use.'], $data['errors']['email']);
}
/**
diff --git a/tests/Unit/Common/Serializer/CursorPaginationNormalizerTest.php b/tests/Unit/Common/Serializer/CursorPaginationNormalizerTest.php
index 61495c61..e2a5b2a1 100644
--- a/tests/Unit/Common/Serializer/CursorPaginationNormalizerTest.php
+++ b/tests/Unit/Common/Serializer/CursorPaginationNormalizerTest.php
@@ -22,7 +22,6 @@ public function testNormalizeWithItems(): void
$result = $normalizer->normalize($paginationResult);
- $this->assertIsArray($result);
$this->assertEquals($items, $result['items']);
$this->assertEquals([
'total' => 10,
diff --git a/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php b/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php
index c2f065cf..6c11f434 100644
--- a/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php
+++ b/tests/Unit/Common/Service/Provider/PaginatedDataProviderTest.php
@@ -61,7 +61,6 @@ className: 'Some\\Entity\\Class',
filter: new PaginatedFilter(),
);
- $this->assertIsArray($result);
$this->assertArrayHasKey('items', $result);
$this->assertArrayHasKey('pagination', $result);
}
diff --git a/tests/Unit/Common/Validator/RequestValidatorTest.php b/tests/Unit/Common/Validator/RequestValidatorTest.php
index ce9adef5..69e3750d 100644
--- a/tests/Unit/Common/Validator/RequestValidatorTest.php
+++ b/tests/Unit/Common/Validator/RequestValidatorTest.php
@@ -12,6 +12,7 @@
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
@@ -29,7 +30,8 @@ protected function setUp(): void
$this->validator = $this->createMock(ValidatorInterface::class);
$this->requestValidator = new RequestValidator(
$this->serializer,
- $this->validator
+ $this->validator,
+ new CamelCaseToSnakeCaseNameConverter()
);
}
@@ -113,6 +115,38 @@ public function testValidateThrowsOnConstraintViolations(): void
$this->requestValidator->validate($request, DummyRequestDto::class);
}
+ public function testValidateConvertsPropertyPathToSnakeCase(): void
+ {
+ $dto = $this->createMock(RequestInterface::class);
+ $json = '{"list_position":-1}';
+ $request = new Request([], [], [], [], [], [], $json);
+
+ $this->serializer
+ ->expects(self::once())
+ ->method('denormalize')
+ ->willReturn($dto);
+
+ $violation = new ConstraintViolation(
+ 'This value should be positive.',
+ '',
+ [],
+ null,
+ 'listPosition',
+ -1
+ );
+ $violations = new ConstraintViolationList([$violation]);
+
+ $this->validator
+ ->method('validate')
+ ->with($dto)
+ ->willReturn($violations);
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('list_position: This value should be positive.');
+
+ $this->requestValidator->validate($request, DummyRequestDto::class);
+ }
+
public function testValidateMergesRouteParams(): void
{
$dto = $this->createMock(RequestInterface::class);
diff --git a/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php b/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php
index ae9cb6a3..15b5a986 100644
--- a/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php
+++ b/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php
@@ -31,8 +31,6 @@ public function testValidateSkipsNull(): void
$this->entityManager->expects(self::never())->method('find');
$this->validator->validate(null, new UniqueConfigKey());
-
- self::assertTrue(true);
}
public function testValidateSkipsEmptyString(): void
@@ -40,8 +38,6 @@ public function testValidateSkipsEmptyString(): void
$this->entityManager->expects(self::never())->method('find');
$this->validator->validate('', new UniqueConfigKey());
-
- self::assertTrue(true);
}
public function testValidateThrowsUnexpectedTypeException(): void
@@ -81,7 +77,5 @@ public function testValidatePassesIfConfigKeyIsUnique(): void
->willReturn(null);
$this->validator->validate('new_config_key', new UniqueConfigKey());
-
- self::assertTrue(true);
}
}
diff --git a/tests/Unit/Identity/Serializer/AdminAttributeDefinitionNormalizerTest.php b/tests/Unit/Identity/Serializer/AdminAttributeDefinitionNormalizerTest.php
index 8bc4396b..f7df8ce5 100644
--- a/tests/Unit/Identity/Serializer/AdminAttributeDefinitionNormalizerTest.php
+++ b/tests/Unit/Identity/Serializer/AdminAttributeDefinitionNormalizerTest.php
@@ -24,7 +24,6 @@ public function testNormalizeReturnsExpectedArray(): void
$normalizer = new AdminAttributeDefinitionNormalizer();
$data = $normalizer->normalize($definition);
- $this->assertIsArray($data);
$this->assertEquals([
'id' => 123,
'name' => 'test_attribute',
@@ -40,7 +39,6 @@ public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void
$normalizer = new AdminAttributeDefinitionNormalizer();
$data = $normalizer->normalize(new \stdClass());
- $this->assertIsArray($data);
$this->assertEmpty($data);
}
diff --git a/tests/Unit/Identity/Serializer/AdminAttributeValueNormalizerTest.php b/tests/Unit/Identity/Serializer/AdminAttributeValueNormalizerTest.php
index 3299cd73..fcb371dc 100644
--- a/tests/Unit/Identity/Serializer/AdminAttributeValueNormalizerTest.php
+++ b/tests/Unit/Identity/Serializer/AdminAttributeValueNormalizerTest.php
@@ -54,7 +54,6 @@ public function testNormalizeReturnsExpectedArray(): void
$data = $this->normalizer->normalize($attributeValue);
- $this->assertIsArray($data);
$this->assertEquals([
'administrator' => ['id' => 1, 'login_name' => 'admin'],
'definition' => ['id' => 2, 'name' => 'test_attribute'],
@@ -78,7 +77,6 @@ public function testNormalizeUsesDefaultValueWhenValueIsNull(): void
$data = $this->normalizer->normalize($attributeValue);
- $this->assertIsArray($data);
$this->assertEquals([
'administrator' => ['id' => 1, 'login_name' => 'admin'],
'definition' => ['id' => 2, 'name' => 'test_attribute'],
@@ -90,7 +88,6 @@ public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void
{
$data = $this->normalizer->normalize(new \stdClass());
- $this->assertIsArray($data);
$this->assertEmpty($data);
}
diff --git a/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php b/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php
index 68ee51c8..d9a51bd3 100644
--- a/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php
+++ b/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php
@@ -31,7 +31,6 @@ public function testNormalizeValidAdministrator(): void
$normalizer = new AdministratorNormalizer();
$data = $normalizer->normalize($admin);
- $this->assertIsArray($data);
$this->assertEquals([
'id' => 123,
'login_name' => 'admin',
diff --git a/tests/Unit/Identity/Vallidator/Constraint/UniqueEmailValidatorTest.php b/tests/Unit/Identity/Vallidator/Constraint/UniqueEmailValidatorTest.php
index 5f0ab2f1..3ebd5e0d 100644
--- a/tests/Unit/Identity/Vallidator/Constraint/UniqueEmailValidatorTest.php
+++ b/tests/Unit/Identity/Vallidator/Constraint/UniqueEmailValidatorTest.php
@@ -11,11 +11,11 @@
use PhpList\RestBundle\Subscription\Validator\Constraint\UniqueEmailValidator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
-use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
+use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
class UniqueEmailValidatorTest extends TestCase
{
@@ -53,7 +53,7 @@ public function testThrowsUnexpectedValueExceptionForNonString(): void
$this->validator->validate(123, new UniqueEmail(Subscriber::class));
}
- public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDifferentId(): void
+ public function testAddsViolationWhenEmailAlreadyExistsWithDifferentId(): void
{
$email = 'foo@bar.com';
@@ -81,10 +81,18 @@ public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDiffere
->method('getObject')
->willReturn($dto);
- $this->expectException(ConflictHttpException::class);
- $this->expectExceptionMessage('Email already exists.');
+ $constraint = new UniqueEmail(Subscriber::class);
- $this->validator->validate($email, new UniqueEmail(Subscriber::class));
+ $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class);
+ $violationBuilder->expects(self::once())->method('addViolation');
+
+ $this->context
+ ->expects(self::once())
+ ->method('buildViolation')
+ ->with($constraint->message)
+ ->willReturn($violationBuilder);
+
+ $this->validator->validate($email, $constraint);
}
public function testAllowsSameEmailForSameSubscriberId(): void
diff --git a/tests/Unit/Identity/Vallidator/Constraint/UniqueLoginNameValidatorTest.php b/tests/Unit/Identity/Vallidator/Constraint/UniqueLoginNameValidatorTest.php
index f96c63f7..5a5d9da6 100644
--- a/tests/Unit/Identity/Vallidator/Constraint/UniqueLoginNameValidatorTest.php
+++ b/tests/Unit/Identity/Vallidator/Constraint/UniqueLoginNameValidatorTest.php
@@ -9,8 +9,8 @@
use PhpList\RestBundle\Identity\Validator\Constraint\UniqueLoginName;
use PhpList\RestBundle\Identity\Validator\Constraint\UniqueLoginNameValidator;
use PHPUnit\Framework\TestCase;
-use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
+use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
class UniqueLoginNameValidatorTest extends TestCase
{
@@ -27,11 +27,9 @@ public function testValidateWithUniqueLoginName(): void
$constraint = new UniqueLoginName();
$validator->validate('new_login', $constraint);
-
- $this->assertTrue(true);
}
- public function testValidateThrowsConflictForExistingLoginName(): void
+ public function testValidateAddsViolationForExistingLoginName(): void
{
$admin = $this->createMock(Administrator::class);
$admin->method('getId')->willReturn(2);
@@ -46,12 +44,24 @@ public function testValidateThrowsConflictForExistingLoginName(): void
$context->method('getObject')->willReturn($dto);
+ $constraint = new UniqueLoginName();
+
+ $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class);
+ $violationBuilder->expects(self::once())
+ ->method('setParameter')
+ ->with('{{ value }}', 'duplicate_login')
+ ->willReturn($violationBuilder);
+ $violationBuilder->expects(self::once())->method('addViolation');
+
+ $context
+ ->expects(self::once())
+ ->method('buildViolation')
+ ->with($constraint->message)
+ ->willReturn($violationBuilder);
+
$validator = new UniqueLoginNameValidator($repository);
$validator->initialize($context);
- $this->expectException(ConflictHttpException::class);
-
- $constraint = new UniqueLoginName();
$validator->validate('duplicate_login', $constraint);
}
@@ -64,8 +74,13 @@ public function testValidateSkipsConflictIfSameAdministrator(): void
$repository->method('findOneBy')->willReturn($admin);
$context = $this->createMock(ExecutionContextInterface::class);
+
+ $context
+ ->expects($this->never())
+ ->method('buildViolation');
+
$dto = new class {
- public $updatingId = 1;
+ public int $updatingId = 1;
};
$context->method('getObject')->willReturn($dto);
@@ -75,7 +90,5 @@ public function testValidateSkipsConflictIfSameAdministrator(): void
$constraint = new UniqueLoginName();
$validator->validate('same_login', $constraint);
-
- $this->assertTrue(true);
}
}
diff --git a/tests/Unit/Messaging/Controller/EditorUploadControllerTest.php b/tests/Unit/Messaging/Controller/EditorUploadControllerTest.php
index 3987f3d7..03d92809 100644
--- a/tests/Unit/Messaging/Controller/EditorUploadControllerTest.php
+++ b/tests/Unit/Messaging/Controller/EditorUploadControllerTest.php
@@ -8,7 +8,7 @@
use PhpList\Core\Domain\Common\Service\DirectoryListingService;
use PhpList\Core\Domain\Common\Validator\UploadDirectoryValidator;
use PhpList\Core\Domain\Identity\Model\Administrator;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\Core\Domain\Common\Model\UploadResult;
use PhpList\Core\Domain\Common\Service\UploadService;
use PhpList\RestBundle\Common\Validator\RequestValidator;
@@ -231,7 +231,7 @@ public function testListFilesReturnsCorrectStructure(): void
->expects(self::once())
->method('generate')
->with(
- 'editor_uploads_get_file',
+ 'api_editor_uploads_get_file',
['filename' => 'test.png'],
UrlGeneratorInterface::ABSOLUTE_URL
)
diff --git a/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php
index 5e2d213c..a7047f45 100644
--- a/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php
+++ b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php
@@ -54,9 +54,6 @@ public function testValidateRegexPatternWithValidRegexDoesNotAddViolation(): voi
$context->expects($this->never())->method('buildViolation');
$req->validateRegexPattern($context);
-
- // if no exception and no violation calls, the test passes
- $this->assertTrue(true);
}
public function testValidateRegexPatternWithInvalidRegexAddsViolation(): void
diff --git a/tests/Unit/Messaging/Request/ForwardMessageRequestTest.php b/tests/Unit/Messaging/Request/ForwardMessageRequestTest.php
index aeb66366..81e547bb 100644
--- a/tests/Unit/Messaging/Request/ForwardMessageRequestTest.php
+++ b/tests/Unit/Messaging/Request/ForwardMessageRequestTest.php
@@ -21,7 +21,6 @@ public function testGetDtoReturnsCorrectArray(): void
$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']);
@@ -41,7 +40,6 @@ public function testGetDtoHandlesNullables(): void
$dto = $request->getDto();
- $this->assertIsArray($dto);
$this->assertSame(['friend@example.com'], $dto['recipients']);
$this->assertSame('fwd-uid-1', $dto['uid']);
$this->assertNull($dto['note']);
diff --git a/tests/Unit/Messaging/Serializer/ForwardingResultNormalizerTest.php b/tests/Unit/Messaging/Serializer/ForwardingResultNormalizerTest.php
index 45e65527..e452487a 100644
--- a/tests/Unit/Messaging/Serializer/ForwardingResultNormalizerTest.php
+++ b/tests/Unit/Messaging/Serializer/ForwardingResultNormalizerTest.php
@@ -48,7 +48,6 @@ public function testNormalizeMapsAllTopLevelCounts(): void
$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']);
diff --git a/tests/Unit/Messaging/Serializer/TemplateImageNormalizerTest.php b/tests/Unit/Messaging/Serializer/TemplateImageNormalizerTest.php
index 7db54275..8e507cf5 100644
--- a/tests/Unit/Messaging/Serializer/TemplateImageNormalizerTest.php
+++ b/tests/Unit/Messaging/Serializer/TemplateImageNormalizerTest.php
@@ -40,7 +40,6 @@ public function testNormalizeTemplateImage(): void
$normalized = $this->normalizer->normalize($templateImage);
- $this->assertIsArray($normalized);
$this->assertEquals([
'id' => 10,
'template_id' => 42,
@@ -56,7 +55,6 @@ public function testNormalizeReturnsEmptyArrayForInvalidObject(): void
{
$normalized = $this->normalizer->normalize(new \stdClass());
- $this->assertIsArray($normalized);
$this->assertEmpty($normalized);
}
}
diff --git a/tests/Unit/Messaging/Serializer/TemplateNormalizerTest.php b/tests/Unit/Messaging/Serializer/TemplateNormalizerTest.php
index acb70cff..cc46bf3b 100644
--- a/tests/Unit/Messaging/Serializer/TemplateNormalizerTest.php
+++ b/tests/Unit/Messaging/Serializer/TemplateNormalizerTest.php
@@ -52,7 +52,6 @@ public function testNormalizeTemplateWithImages(): void
$normalized = $this->normalizer->normalize($template);
- $this->assertIsArray($normalized);
$this->assertEquals([
'id' => 1,
'title' => 'Test Template',
@@ -81,7 +80,6 @@ public function testNormalizeTemplateWithoutImages(): void
$normalized = $this->normalizer->normalize($template);
- $this->assertIsArray($normalized);
$this->assertEquals([
'id' => 2,
'title' => 'Empty Template',
@@ -96,7 +94,6 @@ public function testNormalizeReturnsEmptyArrayForInvalidObject(): void
{
$normalized = $this->normalizer->normalize(new \stdClass());
- $this->assertIsArray($normalized);
$this->assertEmpty($normalized);
}
}
diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php
index e328fe97..042a6711 100644
--- a/tests/Unit/Messaging/Service/CampaignServiceTest.php
+++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php
@@ -4,14 +4,18 @@
namespace PhpList\RestBundle\Tests\Unit\Messaging\Service;
+use DateTime;
+use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Identity\Model\Administrator;
-use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
-use PhpList\Core\Domain\Identity\Model\Privileges;
+use PhpList\Core\Domain\Identity\Service\PermissionChecker;
use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
use PhpList\Core\Domain\Messaging\Model\Dto\UpdateMessageDto;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider;
use PhpList\RestBundle\Messaging\Request\CreateMessageRequest;
@@ -22,6 +26,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CampaignServiceTest extends TestCase
@@ -29,6 +34,7 @@ class CampaignServiceTest extends TestCase
private MessageManager|MockObject $messageManager;
private PaginatedDataProvider|MockObject $paginatedProvider;
private MessageNormalizer|MockObject $normalizer;
+ private PermissionChecker|MockObject $permissionChecker;
private CampaignService $campaignService;
protected function setUp(): void
@@ -36,12 +42,15 @@ protected function setUp(): void
$this->messageManager = $this->createMock(MessageManager::class);
$this->paginatedProvider = $this->createMock(PaginatedDataProvider::class);
$this->normalizer = $this->createMock(MessageNormalizer::class);
+ $this->permissionChecker = $this->createMock(PermissionChecker::class);
$this->campaignService = new CampaignService(
messageManager: $this->messageManager,
paginatedProvider: $this->paginatedProvider,
normalizer: $this->normalizer,
entityManager: $this->createMock(EntityManagerInterface::class),
+ permissionChecker: $this->permissionChecker,
+ stuckCampaignThresholdSeconds: 1800,
);
}
@@ -68,6 +77,52 @@ public function testGetMessagesReturnsExpectedResult(): void
$this->assertSame($expectedResult, $result);
}
+ public function testGetMessagesAppliesStatusAndSortFromQuery(): void
+ {
+ $request = new Request(query: ['status' => 'submitted,prepared', 'sort' => 'desc']);
+ $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) {
+ return $filter->getStatus() === 'submitted,prepared' && $filter->getSortOrder() === 'desc';
+ })
+ )
+ ->willReturn($expectedResult);
+
+ $result = $this->campaignService->getMessages($request, $administrator);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
+ public function testGetMessagesIgnoresInvalidSortValue(): void
+ {
+ $request = new Request(query: ['sort' => 'bogus']);
+ $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) {
+ return $filter->getSortOrder() === 'asc';
+ })
+ )
+ ->willReturn($expectedResult);
+
+ $result = $this->campaignService->getMessages($request, $administrator);
+
+ $this->assertSame($expectedResult, $result);
+ }
+
public function testGetMessageThrowsExceptionWhenMessageIsNull(): void
{
$this->expectException(NotFoundHttpException::class);
@@ -94,16 +149,11 @@ public function testGetMessageReturnsNormalizedMessage(): void
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)
+ $this->permissionChecker->expects($this->once())
+ ->method('canCreate')
+ ->with($administrator, Message::class)
->willReturn(false);
$this->expectException(AccessDeniedHttpException::class);
@@ -116,18 +166,13 @@ 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)
+ $this->permissionChecker->expects($this->once())
+ ->method('canCreate')
+ ->with($administrator, Message::class)
->willReturn(true);
$createMessageRequest->expects($this->once())
@@ -152,17 +197,12 @@ public function testCreateMessageReturnsNormalizedMessage(): void
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)
+ $this->permissionChecker->expects($this->once())
+ ->method('canEdit')
+ ->with($administrator, $message)
->willReturn(false);
$this->expectException(AccessDeniedHttpException::class);
@@ -174,17 +214,10 @@ public function testUpdateMessageThrowsExceptionWhenAdministratorLacksPrivileges
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->permissionChecker->expects($this->never())
+ ->method('canEdit');
$this->expectException(NotFoundHttpException::class);
$this->expectExceptionMessage('Campaign not found.');
@@ -196,19 +229,14 @@ 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)
+ $this->permissionChecker->expects($this->once())
+ ->method('canEdit')
+ ->with($administrator, $message)
->willReturn(true);
$updateMessageRequest->expects($this->once())
@@ -236,17 +264,12 @@ public function testUpdateMessageReturnsNormalizedMessage(): void
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)
+ $this->permissionChecker->expects($this->once())
+ ->method('canDelete')
+ ->with($administrator, $message)
->willReturn(false);
$this->expectException(AccessDeniedHttpException::class);
@@ -257,17 +280,10 @@ public function testDeleteMessageThrowsExceptionWhenAdministratorLacksPrivileges
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->permissionChecker->expects($this->never())
+ ->method('canDelete');
$this->expectException(NotFoundHttpException::class);
$this->expectExceptionMessage('Campaign not found.');
@@ -277,17 +293,12 @@ public function testDeleteMessageThrowsExceptionWhenMessageIsNull(): void
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)
+ $this->permissionChecker->expects($this->once())
+ ->method('canDelete')
+ ->with($administrator, $message)
->willReturn(true);
$this->messageManager->expects($this->once())
@@ -296,4 +307,95 @@ public function testDeleteMessageCallsMessageManagerDelete(): void
$this->campaignService->deleteMessage($administrator, $message);
}
+
+ public function testGetStuckCampaignsReturnsMappedArray(): void
+ {
+ $message = $this->createMock(Message::class);
+ $content = $this->createMock(MessageContent::class);
+ $metadata = $this->createMock(MessageMetadata::class);
+ $updatedAt = new DateTime('-45 minutes');
+
+ $content->method('getSubject')->willReturn('Stuck Campaign');
+ $metadata->method('getStatus')->willReturn(MessageStatus::InProcess);
+ $message->method('getId')->willReturn(7);
+ $message->method('getContent')->willReturn($content);
+ $message->method('getMetadata')->willReturn($metadata);
+ $message->method('getUpdatedAt')->willReturn($updatedAt);
+
+ $this->messageManager->expects($this->once())
+ ->method('getStuckCampaigns')
+ ->with($this->isInstanceOf(DateTimeImmutable::class))
+ ->willReturn([$message]);
+
+ $result = $this->campaignService->getStuckCampaigns();
+
+ $this->assertCount(1, $result);
+ $this->assertSame(7, $result[0]['id']);
+ $this->assertSame('Stuck Campaign', $result[0]['subject']);
+ $this->assertSame('inprocess', $result[0]['status']);
+ $this->assertGreaterThanOrEqual(45 * 60, $result[0]['stuck_seconds']);
+ }
+
+ public function testResumeStuckCampaignThrowsExceptionWhenAdministratorLacksPrivileges(): void
+ {
+ $administrator = $this->createMock(Administrator::class);
+ $message = $this->createMock(Message::class);
+
+ $this->permissionChecker->expects($this->once())
+ ->method('canEdit')
+ ->with($administrator, $message)
+ ->willReturn(false);
+
+ $this->expectException(AccessDeniedHttpException::class);
+
+ $this->campaignService->resumeStuckCampaign($administrator, $message);
+ }
+
+ public function testResumeStuckCampaignThrowsExceptionWhenMessageIsNull(): void
+ {
+ $administrator = $this->createMock(Administrator::class);
+
+ $this->permissionChecker->expects($this->never())
+ ->method('canEdit');
+
+ $this->expectException(NotFoundHttpException::class);
+
+ $this->campaignService->resumeStuckCampaign($administrator, null);
+ }
+
+ public function testResumeStuckCampaignThrowsConflictWhenCampaignIsNotStuck(): void
+ {
+ $administrator = $this->createMock(Administrator::class);
+ $message = $this->createMock(Message::class);
+ $otherStuckMessage = $this->createMock(Message::class);
+
+ $this->permissionChecker->method('canEdit')->willReturn(true);
+
+ $message->method('getId')->willReturn(1);
+ $otherStuckMessage->method('getId')->willReturn(2);
+
+ $this->messageManager->expects($this->once())
+ ->method('getStuckCampaigns')
+ ->willReturn([$otherStuckMessage]);
+
+ $this->expectException(ConflictHttpException::class);
+
+ $this->campaignService->resumeStuckCampaign($administrator, $message);
+ }
+
+ public function testResumeStuckCampaignSucceedsWhenCampaignIsStuck(): void
+ {
+ $administrator = $this->createMock(Administrator::class);
+ $message = $this->createMock(Message::class);
+
+ $this->permissionChecker->method('canEdit')->willReturn(true);
+
+ $message->method('getId')->willReturn(1);
+
+ $this->messageManager->expects($this->once())
+ ->method('getStuckCampaigns')
+ ->willReturn([$message]);
+
+ $this->campaignService->resumeStuckCampaign($administrator, $message);
+ }
}
diff --git a/tests/Unit/Messaging/Validator/Constraint/ContainsPlaceholderValidatorTest.php b/tests/Unit/Messaging/Validator/Constraint/ContainsPlaceholderValidatorTest.php
index bdd2105f..d0c24f01 100644
--- a/tests/Unit/Messaging/Validator/Constraint/ContainsPlaceholderValidatorTest.php
+++ b/tests/Unit/Messaging/Validator/Constraint/ContainsPlaceholderValidatorTest.php
@@ -22,8 +22,6 @@ public function testValidateWithValidPlaceholder(): void
$constraint = new ContainsPlaceholder(['placeholder' => '[CONTENT]']);
$validator->validate('[CONTENT]', $constraint);
-
- $this->assertTrue(true);
}
public function testValidateWithMissingPlaceholder(): void
diff --git a/tests/Unit/Messaging/Validator/Constraint/MaxForwardCountValidatorTest.php b/tests/Unit/Messaging/Validator/Constraint/MaxForwardCountValidatorTest.php
index 3360749d..3729e54b 100644
--- a/tests/Unit/Messaging/Validator/Constraint/MaxForwardCountValidatorTest.php
+++ b/tests/Unit/Messaging/Validator/Constraint/MaxForwardCountValidatorTest.php
@@ -22,8 +22,6 @@ public function testSkipsWhenValueIsNotArray(): void
$constraint = new MaxForwardCount();
$validator->validate('not-an-array', $constraint);
-
- $this->assertTrue(true);
}
public function testTriggersViolationWhenUniqueCountExceedsLimit(): void
@@ -73,6 +71,5 @@ public function testNoViolationWhenWithinLimit(): void
$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
index dc38dcf1..594c7293 100644
--- a/tests/Unit/Messaging/Validator/Constraint/MaxPersonalNoteSizeValidatorTest.php
+++ b/tests/Unit/Messaging/Validator/Constraint/MaxPersonalNoteSizeValidatorTest.php
@@ -23,8 +23,6 @@ public function testSkipsWhenValueIsNullOrEmpty(): void
$constraint = new MaxPersonalNoteSize();
$validator->validate(null, $constraint);
$validator->validate('', $constraint);
-
- $this->assertTrue(true);
}
public function testSkipsWhenMaxSizeIsNullOrNegative(): void
@@ -39,8 +37,6 @@ public function testSkipsWhenMaxSizeIsNullOrNegative(): void
$validatorNeg = new MaxPersonalNoteSizeValidator(-1);
$validatorNeg->initialize($context);
$validatorNeg->validate('anything', new MaxPersonalNoteSize());
-
- $this->assertTrue(true);
}
public function testNoViolationWhenWithinOrAtLimit(): void
@@ -59,8 +55,6 @@ public function testNoViolationWhenWithinOrAtLimit(): void
// below limit
$short = str_repeat('b', 5);
$validator->validate($short, $constraint);
-
- $this->assertTrue(true);
}
public function testViolationWhenExceedsLimit(): void
diff --git a/tests/Unit/Messaging/Validator/Constraint/TemplateExistsValidatorTest.php b/tests/Unit/Messaging/Validator/Constraint/TemplateExistsValidatorTest.php
index 8d6ca8f0..b3bc52de 100644
--- a/tests/Unit/Messaging/Validator/Constraint/TemplateExistsValidatorTest.php
+++ b/tests/Unit/Messaging/Validator/Constraint/TemplateExistsValidatorTest.php
@@ -34,14 +34,12 @@ public function testValidateSkipsNull(): void
{
$this->templateRepository->expects($this->never())->method('find');
$this->validator->validate(null, new TemplateExists());
- $this->assertTrue(true);
}
public function testValidateSkipsEmptyString(): void
{
$this->templateRepository->expects($this->never())->method('find');
$this->validator->validate('', new TemplateExists());
- $this->assertTrue(true);
}
public function testValidateThrowsUnexpectedTypeException(): void
@@ -81,6 +79,5 @@ public function testValidatePassesIfTemplateExists(): void
->willReturn($template);
$this->validator->validate(1, new TemplateExists());
- $this->assertTrue(true);
}
}
diff --git a/tests/Unit/Messaging/Validator/Constraint/UniqueTemplateTitleValidatorTest.php b/tests/Unit/Messaging/Validator/Constraint/UniqueTemplateTitleValidatorTest.php
index 5c29ebbd..e82d1cc4 100644
--- a/tests/Unit/Messaging/Validator/Constraint/UniqueTemplateTitleValidatorTest.php
+++ b/tests/Unit/Messaging/Validator/Constraint/UniqueTemplateTitleValidatorTest.php
@@ -35,14 +35,12 @@ public function testValidateSkipsNull(): void
{
$this->templateRepository->expects($this->never())->method('findOneBy');
$this->validator->validate(null, new UniqueTemplateTitle());
- $this->assertTrue(true);
}
public function testValidateSkipsEmptyString(): void
{
$this->templateRepository->expects($this->never())->method('findOneBy');
$this->validator->validate('', new UniqueTemplateTitle());
- $this->assertTrue(true);
}
public function testValidateThrowsUnexpectedTypeException(): void
@@ -82,7 +80,6 @@ public function testValidatePassesIfTemplateTitleIsUnique(): void
->willReturn(null);
$this->validator->validate('Unique Template', new UniqueTemplateTitle());
- $this->assertTrue(true);
}
public function testValidateSkipsConflictForSameTemplateOnUpdate(): void
@@ -104,6 +101,5 @@ public function testValidateSkipsConflictForSameTemplateOnUpdate(): void
->willReturn($dto);
$this->validator->validate('Existing Title', new UniqueTemplateTitle());
- $this->assertTrue(true);
}
}
diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php
index 6bbdc7d2..c6a6ab73 100644
--- a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php
+++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php
@@ -8,10 +8,11 @@
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Identity\Model\PrivilegeFlag;
use PhpList\Core\Domain\Identity\Model\Privileges;
-use PhpList\Core\Security\Authentication;
+use PhpList\Core\Domain\Identity\Service\Authentication;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use PhpList\RestBundle\Statistics\Controller\AnalyticsController;
use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer;
+use PhpList\RestBundle\Statistics\Serializer\DomainConfirmationNormalizer;
use PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer;
use PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer;
use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer;
@@ -46,7 +47,8 @@ protected function setUp(): void
campaignStatsNormalizer: $campaignStatisticsNormalizer,
viewOpensStatsNormalizer: $viewOpensStatisticsNormalizer,
topDomainsNormalizer: $topDomainsNormalizer,
- topLocalPartsNormalizer: new TopLocalPartsNormalizer()
+ topLocalPartsNormalizer: new TopLocalPartsNormalizer(),
+ domainConfirmationNormalizer: new DomainConfirmationNormalizer(),
);
$this->privileges = $this->createMock(Privileges::class);
@@ -292,7 +294,15 @@ public function testGetTopDomainsReturnsJsonResponse(): void
self::assertInstanceOf(JsonResponse::class, $response);
self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
- self::assertEquals($expectedData, json_decode($response->getContent(), true));
+ self::assertEquals([
+ 'items' => [
+ [
+ 'domain' => 'example.com',
+ 'subscribers' => 50,
+ ]
+ ],
+ 'total' => 1,
+ ], json_decode($response->getContent(), true));
}
public function testGetDomainConfirmationStatisticsWithoutStatisticsPrivilegeThrowsException(): void
@@ -369,7 +379,30 @@ public function testGetDomainConfirmationStatisticsReturnsJsonResponse(): void
self::assertInstanceOf(JsonResponse::class, $response);
self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
- self::assertEquals($expectedData, json_decode($response->getContent(), true));
+ self::assertEquals([
+ 'items' => [
+ [
+ '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,
+ ], json_decode($response->getContent(), true));
}
public function testGetTopLocalPartsWithoutStatisticsPrivilegeThrowsException(): void
@@ -432,37 +465,18 @@ public function testGetTopLocalPartsReturnsJsonResponse(): void
self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
self::assertEquals([
- 'local_parts' => [
+ 'items' => [
[
'local_part' => 'info',
'count' => 30,
- 'percentage' => 60.0,
+ 'percentage' => 60,
]
],
'total' => 1,
], json_decode($response->getContent(), true));
}
- public function testGetDashboardStatisticsWithoutStatisticsPrivilegeDoesNotThrowException(): void
- {
- $request = new Request();
-
- $this->authentication
- ->expects(self::once())
- ->method('authenticateByApiKey')
- ->with($request)
- ->willReturn($this->administrator);
-
- $this->privileges
- ->expects(self::never())
- ->method('has')
- ->with(PrivilegeFlag::Statistics)
- ->willReturn(false);
-
- $this->controller->getDashboardStatistics($request);
- }
-
- public function testGetDashboardStatisticsReturnsJsonResponse(): void
+ public function testGetDashboardSummaryDoesNotCheckStatisticsPrivilege(): void
{
$request = new Request();
@@ -475,8 +489,7 @@ public function testGetDashboardStatisticsReturnsJsonResponse(): void
$this->privileges
->expects(self::never())
->method('has')
- ->with(PrivilegeFlag::Statistics)
- ->willReturn(true);
+ ->with(PrivilegeFlag::Statistics);
$this->analyticsService
->expects(self::once())
@@ -500,30 +513,98 @@ public function testGetDashboardStatisticsReturnsJsonResponse(): void
],
]);
- $response = $this->controller->getDashboardStatistics($request);
+ $response = $this->controller->getDashboardSummary($request);
self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
self::assertEquals([
- 'summary_statistics' => [
- '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,
- ],
+ '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,
],
- 'recent_campaigns' => [],
- 'campaign_performance' => [],
], json_decode($response->getContent(), true));
}
+
+ public function testGetRecentCampaignsStatisticsReturnsJsonResponse(): void
+ {
+ $request = new Request();
+
+ $this->authentication
+ ->expects(self::once())
+ ->method('authenticateByApiKey')
+ ->with($request)
+ ->willReturn($this->administrator);
+
+ $this->privileges
+ ->expects(self::never())
+ ->method('has')
+ ->with(PrivilegeFlag::Statistics);
+
+ $expectedData = [
+ [
+ 'name' => 'March Newsletter',
+ 'status' => 'sent',
+ 'date' => '2026-03-15',
+ 'open_rate' => '42.50%',
+ 'click_rate' => '8.10%',
+ ],
+ ];
+
+ $this->analyticsService
+ ->expects(self::once())
+ ->method('getRecentCampaigns')
+ ->willReturn($expectedData);
+
+ $response = $this->controller->getRecentCampaignsStatistics($request);
+
+ self::assertInstanceOf(JsonResponse::class, $response);
+ self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
+ self::assertEquals($expectedData, json_decode($response->getContent(), true));
+ }
+
+ public function testGetCampaignPerformanceStatisticsReturnsJsonResponse(): void
+ {
+ $request = new Request();
+
+ $this->authentication
+ ->expects(self::once())
+ ->method('authenticateByApiKey')
+ ->with($request)
+ ->willReturn($this->administrator);
+
+ $this->privileges
+ ->expects(self::never())
+ ->method('has')
+ ->with(PrivilegeFlag::Statistics);
+
+ $expectedData = [
+ [
+ 'date' => '2026-03-19',
+ 'opens' => 234,
+ 'clicks' => 57,
+ ],
+ ];
+
+ $this->analyticsService
+ ->expects(self::once())
+ ->method('getCampaignPerformance')
+ ->willReturn($expectedData);
+
+ $response = $this->controller->getCampaignPerformanceStatistics($request);
+
+ self::assertInstanceOf(JsonResponse::class, $response);
+ self::assertEquals(Response::HTTP_OK, $response->getStatusCode());
+ self::assertEquals($expectedData, json_decode($response->getContent(), true));
+ }
}
diff --git a/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php b/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php
index 486e27b7..5cf55cbc 100644
--- a/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php
+++ b/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php
@@ -22,15 +22,14 @@ public function testNormalizeWithValidData(): void
$normalizer = new TopDomainsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('domains', $result);
+ $this->assertArrayHasKey('items', $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']);
+ $this->assertCount(2, $result['items']);
+ $this->assertEquals('example.com', $result['items'][0]['domain']);
+ $this->assertEquals(100, $result['items'][0]['subscribers']);
+ $this->assertEquals('test.org', $result['items'][1]['domain']);
+ $this->assertEquals(50, $result['items'][1]['subscribers']);
}
public function testNormalizeWithMissingFields(): void
@@ -46,17 +45,16 @@ public function testNormalizeWithMissingFields(): void
$normalizer = new TopDomainsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('domains', $result);
+ $this->assertArrayHasKey('items', $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']);
+ $this->assertCount(3, $result['items']);
+ $this->assertEquals('example.com', $result['items'][0]['domain']);
+ $this->assertEquals(0, $result['items'][0]['subscribers']);
+ $this->assertEquals('', $result['items'][1]['domain']);
+ $this->assertEquals(50, $result['items'][1]['subscribers']);
+ $this->assertEquals('', $result['items'][2]['domain']);
+ $this->assertEquals(0, $result['items'][2]['subscribers']);
}
public function testNormalizeWithEmptyDomains(): void
@@ -69,11 +67,10 @@ public function testNormalizeWithEmptyDomains(): void
$normalizer = new TopDomainsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('domains', $result);
+ $this->assertArrayHasKey('items', $result);
$this->assertArrayHasKey('total', $result);
$this->assertEquals(0, $result['total']);
- $this->assertEmpty($result['domains']);
+ $this->assertEmpty($result['items']);
}
public function testNormalizeWithNoDomains(): void
@@ -85,11 +82,10 @@ public function testNormalizeWithNoDomains(): void
$normalizer = new TopDomainsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('domains', $result);
+ $this->assertArrayHasKey('items', $result);
$this->assertArrayHasKey('total', $result);
$this->assertEquals(100, $result['total']);
- $this->assertEmpty($result['domains']);
+ $this->assertEmpty($result['items']);
}
public function testNormalizeWithInvalidObject(): void
@@ -97,7 +93,6 @@ public function testNormalizeWithInvalidObject(): void
$normalizer = new TopDomainsNormalizer();
$result = $normalizer->normalize('not an array');
- $this->assertIsArray($result);
$this->assertEmpty($result);
}
diff --git a/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php b/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php
index a08f80fd..4c8bbea3 100644
--- a/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php
+++ b/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php
@@ -22,17 +22,16 @@ public function testNormalizeWithValidData(): void
$normalizer = new TopLocalPartsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('local_parts', $result);
+ $this->assertArrayHasKey('items', $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']);
+ $this->assertCount(2, $result['items']);
+ $this->assertEquals('john', $result['items'][0]['local_part']);
+ $this->assertEquals(100, $result['items'][0]['count']);
+ $this->assertEquals(40.0, $result['items'][0]['percentage']);
+ $this->assertEquals('info', $result['items'][1]['local_part']);
+ $this->assertEquals(50, $result['items'][1]['count']);
+ $this->assertEquals(20.0, $result['items'][1]['percentage']);
}
public function testNormalizeWithMissingFields(): void
@@ -49,23 +48,22 @@ public function testNormalizeWithMissingFields(): void
$normalizer = new TopLocalPartsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('local_parts', $result);
+ $this->assertArrayHasKey('items', $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']);
+ $this->assertCount(4, $result['items']);
+ $this->assertEquals('john', $result['items'][0]['local_part']);
+ $this->assertEquals(0, $result['items'][0]['count']);
+ $this->assertEquals(0.0, $result['items'][0]['percentage']);
+ $this->assertEquals('', $result['items'][1]['local_part']);
+ $this->assertEquals(50, $result['items'][1]['count']);
+ $this->assertEquals(0.0, $result['items'][1]['percentage']);
+ $this->assertEquals('', $result['items'][2]['local_part']);
+ $this->assertEquals(0, $result['items'][2]['count']);
+ $this->assertEquals(20.0, $result['items'][2]['percentage']);
+ $this->assertEquals('', $result['items'][3]['local_part']);
+ $this->assertEquals(0, $result['items'][3]['count']);
+ $this->assertEquals(0.0, $result['items'][3]['percentage']);
}
public function testNormalizeWithEmptyLocalParts(): void
@@ -78,11 +76,10 @@ public function testNormalizeWithEmptyLocalParts(): void
$normalizer = new TopLocalPartsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('local_parts', $result);
+ $this->assertArrayHasKey('items', $result);
$this->assertArrayHasKey('total', $result);
$this->assertEquals(0, $result['total']);
- $this->assertEmpty($result['local_parts']);
+ $this->assertEmpty($result['items']);
}
public function testNormalizeWithNoLocalParts(): void
@@ -94,11 +91,10 @@ public function testNormalizeWithNoLocalParts(): void
$normalizer = new TopLocalPartsNormalizer();
$result = $normalizer->normalize($data);
- $this->assertIsArray($result);
- $this->assertArrayHasKey('local_parts', $result);
+ $this->assertArrayHasKey('items', $result);
$this->assertArrayHasKey('total', $result);
$this->assertEquals(100, $result['total']);
- $this->assertEmpty($result['local_parts']);
+ $this->assertEmpty($result['items']);
}
public function testNormalizeWithInvalidObject(): void
@@ -106,7 +102,6 @@ public function testNormalizeWithInvalidObject(): void
$normalizer = new TopLocalPartsNormalizer();
$result = $normalizer->normalize('not an array');
- $this->assertIsArray($result);
$this->assertEmpty($result);
}
diff --git a/tests/Unit/Subscription/Request/SubscriberAttributeDefinitionRequestTest.php b/tests/Unit/Subscription/Request/SubscriberAttributeDefinitionRequestTest.php
index d96c0585..e5f6b226 100644
--- a/tests/Unit/Subscription/Request/SubscriberAttributeDefinitionRequestTest.php
+++ b/tests/Unit/Subscription/Request/SubscriberAttributeDefinitionRequestTest.php
@@ -31,7 +31,6 @@ public function testGetDtoReturnsCorrectDto(): void
$this->assertEquals(5, $dto->listOrder);
$this->assertEquals('default', $dto->defaultValue);
$this->assertTrue($dto->required);
- $this->assertIsArray($dto->options);
}
public function testGetDtoWithDefaultValues(): void
@@ -47,7 +46,6 @@ public function testGetDtoWithDefaultValues(): void
$this->assertNull($dto->listOrder);
$this->assertNull($dto->defaultValue);
$this->assertFalse($dto->required);
- $this->assertIsArray($dto->options);
$this->assertSame([], $dto->options);
}
diff --git a/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php b/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php
index 3821a8f0..fd56f35b 100644
--- a/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php
+++ b/tests/Unit/Subscription/Serializer/AttributeDefinitionNormalizerTest.php
@@ -36,7 +36,6 @@ public function testNormalize(): void
$normalizer = new AttributeDefinitionNormalizer();
$result = $normalizer->normalize($definition);
- self::assertIsArray($result);
self::assertSame([
'id' => 1,
'name' => 'Country',
@@ -83,8 +82,6 @@ public function testNormalizeWithOptions(): void
$normalizer = new AttributeDefinitionNormalizer();
$result = $normalizer->normalize($definition);
- self::assertIsArray($result);
-
self::assertSame([
'id' => 5,
'name' => 'Country',
diff --git a/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php
index 11bcd0d3..53e521e2 100644
--- a/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php
+++ b/tests/Unit/Subscription/Serializer/SubscriberNormalizerTest.php
@@ -6,6 +6,7 @@
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
+use PhpList\Core\Domain\Subscription\Model\ReadModel\SubscriberHistoryReadModel;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
use PhpList\Core\Domain\Subscription\Model\Subscription;
@@ -43,6 +44,7 @@ public function testNormalize(): void
$subscriber->method('getId')->willReturn(101);
$subscriber->method('getEmail')->willReturn('test@example.com');
$subscriber->method('getCreatedAt')->willReturn(new DateTime('2024-12-31T12:00:00+00:00'));
+ $subscriber->method('getUpdatedAt')->willReturn(new DateTime('2024-12-31T12:00:00+00:00'));
$subscriber->method('isConfirmed')->willReturn(true);
$subscriber->method('isBlacklisted')->willReturn(false);
$subscriber->method('getBounceCount')->willReturn(0);
@@ -58,7 +60,7 @@ public function testNormalize(): void
'id' => 101,
'email' => 'test@example.com',
'created_at' => '2024-12-31T12:00:00+00:00',
- 'updated_at' => null,
+ 'updated_at' => '2024-12-31T12:00:00+00:00',
'confirmed' => true,
'blacklisted' => false,
'bounce_count' => 0,
@@ -90,4 +92,47 @@ public function testNormalizeWithInvalidObject(): void
$normalizer = new SubscriberNormalizer(new SubscriberListNormalizer(), new SubscriberHistoryNormalizer());
$this->assertSame([], $normalizer->normalize(new stdClass()));
}
+
+ public function testNormalizeAcceptsElasticsearchBackedHistoryReadModels(): void
+ {
+ $history = new SubscriberHistoryReadModel(
+ id: 7,
+ subscriberId: 101,
+ ip: '127.0.0.1',
+ createdAt: new DateTime('2025-02-01T00:00:00+00:00'),
+ summary: 'Updated',
+ detail: 'Detail',
+ systemInfo: 'Info',
+ );
+
+ $subscriber = $this->createMock(Subscriber::class);
+ $subscriber->method('getId')->willReturn(101);
+ $subscriber->method('getEmail')->willReturn('test@example.com');
+ $subscriber->method('getCreatedAt')->willReturn(new DateTime('2024-12-31T12:00:00+00:00'));
+ $subscriber->method('getUpdatedAt')->willReturn(new DateTime('2024-12-31T12:00:00+00:00'));
+ $subscriber->method('isConfirmed')->willReturn(true);
+ $subscriber->method('isBlacklisted')->willReturn(false);
+ $subscriber->method('getBounceCount')->willReturn(0);
+ $subscriber->method('getUniqueId')->willReturn('abc123');
+ $subscriber->method('getUuid')->willReturn('abc-123-abc-123');
+ $subscriber->method('hasHtmlEmail')->willReturn(true);
+ $subscriber->method('isDisabled')->willReturn(false);
+ $subscriber->method('getSubscriptions')->willReturn(new ArrayCollection([]));
+ $subscriber->method('getHistory')->willReturn([$history]);
+
+ $normalizer = new SubscriberNormalizer(new SubscriberListNormalizer(), new SubscriberHistoryNormalizer());
+
+ $result = $normalizer->normalize($subscriber);
+
+ $this->assertSame([
+ [
+ 'id' => 7,
+ 'ip' => '127.0.0.1',
+ 'created_at' => '2025-02-01T00:00:00+00:00',
+ 'summary' => 'Updated',
+ 'detail' => 'Detail',
+ 'system_info' => 'Info',
+ ],
+ ], $result['history']);
+ }
}
diff --git a/tests/Unit/Subscription/Validator/Constraint/EmailExistsValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/EmailExistsValidatorTest.php
index 9dccd7b2..6ec816af 100644
--- a/tests/Unit/Subscription/Validator/Constraint/EmailExistsValidatorTest.php
+++ b/tests/Unit/Subscription/Validator/Constraint/EmailExistsValidatorTest.php
@@ -34,14 +34,12 @@ public function testValidateSkipsNull(): void
{
$this->subscriberRepository->expects($this->never())->method('findOneBy');
$this->validator->validate(null, new EmailExists());
- $this->assertTrue(true);
}
public function testValidateSkipsEmptyString(): void
{
$this->subscriberRepository->expects($this->never())->method('findOneBy');
$this->validator->validate('', new EmailExists());
- $this->assertTrue(true);
}
public function testValidateThrowsUnexpectedTypeException(): void
@@ -81,6 +79,5 @@ public function testValidatePassesIfEmailExists(): void
->willReturn($subscriber);
$this->validator->validate('found@example.com', new EmailExists());
- $this->assertTrue(true);
}
}
diff --git a/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php
index 3c0c6652..4814d153 100644
--- a/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php
+++ b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php
@@ -33,14 +33,12 @@ public function testValidateSkipsNull(): void
{
$this->subscriberListRepository->expects($this->never())->method('findBy');
$this->validator->validate(null, new ListExistsPublic());
- $this->assertTrue(true);
}
public function testValidateSkipsEmptyString(): void
{
$this->subscriberListRepository->expects($this->never())->method('findBy');
$this->validator->validate('', new ListExistsPublic());
- $this->assertTrue(true);
}
public function testValidateThrowsUnexpectedTypeException(): void
@@ -74,6 +72,5 @@ public function testValidatePassesIfPublicListExists(): void
->willReturn([$subscriberList]);
$this->validator->validate('123', new ListExistsPublic());
- $this->assertTrue(true);
}
}
diff --git a/tests/Unit/Subscription/Validator/Constraint/ListExistsValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ListExistsValidatorTest.php
index f8c42a77..febaf39b 100644
--- a/tests/Unit/Subscription/Validator/Constraint/ListExistsValidatorTest.php
+++ b/tests/Unit/Subscription/Validator/Constraint/ListExistsValidatorTest.php
@@ -34,14 +34,12 @@ public function testValidateSkipsNull(): void
{
$this->subscriberListRepository->expects($this->never())->method('find');
$this->validator->validate(null, new ListExists());
- $this->assertTrue(true);
}
public function testValidateSkipsEmptyString(): void
{
$this->subscriberListRepository->expects($this->never())->method('find');
$this->validator->validate('', new ListExists());
- $this->assertTrue(true);
}
public function testValidateThrowsUnexpectedTypeException(): void
@@ -75,6 +73,5 @@ public function testValidatePassesIfListExists(): void
->willReturn($subscriberList);
$this->validator->validate('123', new ListExists());
- $this->assertTrue(true);
}
}
diff --git a/tests/Unit/Subscription/Validator/Constraint/UniqueEmailValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/UniqueEmailValidatorTest.php
index e32852d4..00d0cb81 100644
--- a/tests/Unit/Subscription/Validator/Constraint/UniqueEmailValidatorTest.php
+++ b/tests/Unit/Subscription/Validator/Constraint/UniqueEmailValidatorTest.php
@@ -11,11 +11,11 @@
use PhpList\RestBundle\Subscription\Validator\Constraint\UniqueEmailValidator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
-use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
+use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
class UniqueEmailValidatorTest extends TestCase
{
@@ -53,7 +53,7 @@ public function testThrowsUnexpectedValueExceptionForNonString(): void
$this->validator->validate(123, new UniqueEmail(Subscriber::class));
}
- public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDifferentId(): void
+ public function testAddsViolationWhenEmailAlreadyExistsWithDifferentId(): void
{
$email = 'foo@bar.com';
@@ -81,10 +81,18 @@ public function testThrowsConflictHttpExceptionWhenEmailAlreadyExistsWithDiffere
->method('getObject')
->willReturn($dto);
- $this->expectException(ConflictHttpException::class);
- $this->expectExceptionMessage('Email already exists.');
+ $constraint = new UniqueEmail(Subscriber::class);
- $this->validator->validate($email, new UniqueEmail(Subscriber::class));
+ $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class);
+ $violationBuilder->expects(self::once())->method('addViolation');
+
+ $this->context
+ ->expects(self::once())
+ ->method('buildViolation')
+ ->with($constraint->message)
+ ->willReturn($violationBuilder);
+
+ $this->validator->validate($email, $constraint);
}
public function testAllowsSameEmailForSameSubscriberId(): void
diff --git a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php
index 57a9b619..8e40a3c5 100644
--- a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php
+++ b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php
@@ -40,7 +40,6 @@ public function testSkipsWhenSubscribePageIsMissing(): void
$this->context->expects($this->never())->method('buildViolation');
$this->validator->validate($request, new ValidPublicSubscription());
- $this->assertTrue(true);
}
public function testAddsViolationsForUnknownAndRequiredAttributes(): void
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 00000000..ec555589
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,9 @@
+bootEnv(dirname(__DIR__) . '/.env');