diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..b354963b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +tone_instructions: "chill" +reviews: + profile: "chill" + high_level_summary: true + collapse_walkthrough: true + suggested_labels: false + high_level_summary_in_walkthrough: false + changed_files_summary: false + poem: false + auto_review: + enabled: true + base_branches: + - ".*" + drafts: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b4c9b3f7..aad3619f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,37 +1,3 @@ -### Summary - -Provide a general description of the code changes in your pull request … -were there any bugs you had fixed? If so, mention them. If these bugs have open -GitHub issues, be sure to tag them here as well, to keep the conversation -linked together. - - -### Unit test - -Are your changes covered with unit tests, and do they not break anything? - -You can run the existing unit tests using this command: - - vendor/bin/phpunit tests/ - - -### Code style - -Have you checked that you code is well-documented and follows the PSR-2 coding -style? - -You can check for this using this command: - - vendor/bin/phpcs --standard=PSR2 src/ tests/ - - -### Other Information - -If there's anything else that's important and relevant to your pull -request, mention that information here. This could include benchmarks, -or other information. - -If you are updating any of the CHANGELOG files or are asked to update the -CHANGELOG files by reviewers, please add the CHANGELOG entry at the top of the file. +"@coderabbitai summary" Thanks for contributing to phpList! diff --git a/composer.json b/composer.json index 5b8e2a73..c4c880f8 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,12 @@ "role": "Maintainer" } ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/TatevikGr/rss-bundle.git" + } + ], "support": { "issues": "https://github.com/phpList/rest-api/issues", "forum": "https://discuss.phplist.org/", @@ -41,7 +47,8 @@ "symfony/test-pack": "^1.0", "symfony/process": "^6.4", "zircote/swagger-php": "^4.11", - "ext-dom": "*" + "ext-dom": "*", + "tatevikgr/rss-feed": "dev-main as 0.1.0" }, "require-dev": { "phpunit/phpunit": "^10.0", @@ -123,5 +130,10 @@ } } } + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } } } diff --git a/config/services/managers.yml b/config/services/managers.yml index 37f0b029..99253992 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -20,15 +20,19 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\MessageManager: + PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateImageManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: autowire: true autoconfigure: true diff --git a/config/services/messenger_handlers.yml b/config/services/messenger_handlers.yml new file mode 100644 index 00000000..9073d52f --- /dev/null +++ b/config/services/messenger_handlers.yml @@ -0,0 +1,5 @@ +services: + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: + autowire: true + autoconfigure: true + public: false diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 1d16cc3f..2179f6ad 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -93,3 +93,15 @@ services: PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Subscription\Serializer\UserBlacklistNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true + + PhpList\RestBundle\Messaging\Serializer\BounceRegexNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Identity/Controller/AdminAttributeDefinitionController.php b/src/Identity/Controller/AdminAttributeDefinitionController.php index 772a56d1..ff4c8531 100644 --- a/src/Identity/Controller/AdminAttributeDefinitionController.php +++ b/src/Identity/Controller/AdminAttributeDefinitionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; @@ -32,7 +33,8 @@ public function __construct( RequestValidator $validator, AdminAttributeDefinitionManager $definitionManager, AdminAttributeDefinitionNormalizer $normalizer, - PaginatedDataProvider $paginatedDataProvider + PaginatedDataProvider $paginatedDataProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->definitionManager = $definitionManager; @@ -89,6 +91,8 @@ public function create(Request $request): JsonResponse $definitionRequest = $this->validator->validate($request, CreateAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->create($definitionRequest->getDto()); + $this->entityManager->flush(); + $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -156,6 +160,7 @@ public function update( attributeDefinition: $attributeDefinition, attributeDefinitionDto: $definitionRequest->getDto(), ); + $this->entityManager->flush(); $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_OK); @@ -211,6 +216,7 @@ public function delete( } $this->definitionManager->delete($attributeDefinition); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Identity/Controller/AdminAttributeValueController.php b/src/Identity/Controller/AdminAttributeValueController.php index ca897234..573608ba 100644 --- a/src/Identity/Controller/AdminAttributeValueController.php +++ b/src/Identity/Controller/AdminAttributeValueController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\Filter\AdminAttributeValueFilter; use PhpList\Core\Domain\Identity\Model\Administrator; @@ -27,18 +28,21 @@ class AdminAttributeValueController extends BaseController private AdminAttributeManager $attributeManager; private AdminAttributeValueNormalizer $normalizer; private PaginatedDataProvider $paginatedDataProvider; + private EntityManagerInterface $entityManager; public function __construct( Authentication $authentication, RequestValidator $validator, AdminAttributeManager $attributeManager, AdminAttributeValueNormalizer $normalizer, - PaginatedDataProvider $paginatedDataProvider + PaginatedDataProvider $paginatedDataProvider, + EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->attributeManager = $attributeManager; $this->normalizer = $normalizer; $this->paginatedDataProvider = $paginatedDataProvider; + $this->entityManager = $entityManager; } #[Route( @@ -122,6 +126,7 @@ public function createOrUpdate( definition: $definition, value: $request->toArray()['value'] ?? null ); + $this->entityManager->flush(); $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -193,6 +198,7 @@ public function delete( throw $this->createNotFoundException('Administrator attribute not found.'); } $this->attributeManager->delete($attribute); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -350,6 +356,7 @@ public function getAttributeDefinition( attributeDefinitionId: $definition->getId() ); $this->attributeManager->delete($attribute); + $this->entityManager->flush(); return $this->json( $this->normalizer->normalize($attribute), diff --git a/src/Identity/Controller/AdministratorController.php b/src/Identity/Controller/AdministratorController.php index 77e9288b..365cf125 100644 --- a/src/Identity/Controller/AdministratorController.php +++ b/src/Identity/Controller/AdministratorController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Service\AdministratorManager; @@ -35,7 +36,8 @@ public function __construct( RequestValidator $validator, AdministratorManager $administratorManager, AdministratorNormalizer $normalizer, - PaginatedDataProvider $paginatedProvider + PaginatedDataProvider $paginatedProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->administratorManager = $administratorManager; @@ -149,6 +151,7 @@ public function createAdministrator( $createRequest = $validator->validate($request, CreateAdministratorRequest::class); $administrator = $this->administratorManager->createAdministrator($createRequest->getDto()); + $this->entityManager->flush(); $json = $normalizer->normalize($administrator, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -255,6 +258,7 @@ public function updateAdministrator( /** @var UpdateAdministratorRequest $updateRequest */ $updateRequest = $this->validator->validate($request, UpdateAdministratorRequest::class); $this->administratorManager->updateAdministrator($administrator, $updateRequest->getDto()); + $this->entityManager->flush(); return $this->json($this->normalizer->normalize($administrator), Response::HTTP_OK); } @@ -303,6 +307,7 @@ public function deleteAdministrator( throw $this->createNotFoundException('Administrator not found.'); } $this->administratorManager->deleteAdministrator($administrator); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Identity/Controller/PasswordResetController.php b/src/Identity/Controller/PasswordResetController.php index de5d3d67..a3527b07 100644 --- a/src/Identity/Controller/PasswordResetController.php +++ b/src/Identity/Controller/PasswordResetController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Service\PasswordManager; use PhpList\Core\Security\Authentication; @@ -29,6 +30,7 @@ public function __construct( Authentication $authentication, RequestValidator $validator, PasswordManager $passwordManager, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); @@ -74,6 +76,7 @@ public function requestPasswordReset(Request $request): JsonResponse $resetRequest = $this->validator->validate($request, RequestPasswordResetRequest::class); $this->passwordManager->generatePasswordResetToken($resetRequest->email); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -117,6 +120,7 @@ public function validateToken(Request $request): JsonResponse $validateRequest = $this->validator->validate($request, ValidateTokenRequest::class); $administrator = $this->passwordManager->validatePasswordResetToken($validateRequest->token); + $this->entityManager->flush(); return $this->json([ 'valid' => $administrator !== null]); } @@ -169,6 +173,7 @@ public function resetPassword(Request $request): JsonResponse $resetRequest->token, $resetRequest->newPassword ); + $this->entityManager->flush(); if ($success) { return $this->json([ 'message' => 'Password updated successfully']); diff --git a/src/Identity/Controller/SessionController.php b/src/Identity/Controller/SessionController.php index 5f588119..66b49ce9 100644 --- a/src/Identity/Controller/SessionController.php +++ b/src/Identity/Controller/SessionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Service\SessionManager; @@ -34,6 +35,7 @@ public function __construct( Authentication $authentication, RequestValidator $validator, SessionManager $sessionManager, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); @@ -96,6 +98,7 @@ public function createSession( loginName:$createSessionRequest->loginName, password: $createSessionRequest->password ); + $this->entityManager->flush(); $json = $normalizer->normalize($token, 'json'); @@ -163,6 +166,7 @@ public function deleteSession( } $this->sessionManager->deleteSession($token); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Identity/Request/CreateSessionRequest.php b/src/Identity/Request/CreateSessionRequest.php index fb61b094..abff868a 100644 --- a/src/Identity/Request/CreateSessionRequest.php +++ b/src/Identity/Request/CreateSessionRequest.php @@ -9,11 +9,11 @@ class CreateSessionRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\Type(type: 'string')] public string $loginName; - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\Type(type: 'string')] public string $password; diff --git a/src/Identity/Request/UpdateAttributeDefinitionRequest.php b/src/Identity/Request/UpdateAttributeDefinitionRequest.php index 7313a905..8f387392 100644 --- a/src/Identity/Request/UpdateAttributeDefinitionRequest.php +++ b/src/Identity/Request/UpdateAttributeDefinitionRequest.php @@ -10,7 +10,7 @@ class UpdateAttributeDefinitionRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] public string $name; public ?string $type = null; diff --git a/src/Identity/Request/ValidateTokenRequest.php b/src/Identity/Request/ValidateTokenRequest.php index 481b95fc..ffeb0f84 100644 --- a/src/Identity/Request/ValidateTokenRequest.php +++ b/src/Identity/Request/ValidateTokenRequest.php @@ -9,7 +9,7 @@ class ValidateTokenRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\Type(type: 'string')] public string $token; diff --git a/src/Messaging/Controller/BounceRegexController.php b/src/Messaging/Controller/BounceRegexController.php new file mode 100644 index 00000000..c9e9a1cb --- /dev/null +++ b/src/Messaging/Controller/BounceRegexController.php @@ -0,0 +1,250 @@ +requireAuthentication($request); + $items = $this->manager->getAll(); + $normalized = array_map(fn($bounceRegex) => $this->normalizer->normalize($bounceRegex), $items); + + return $this->json($normalized, Response::HTTP_OK); + } + + #[Route('/{regexHash}', name: 'get_one', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/bounces/regex/{regexHash}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns a bounce regex by its hash.', + summary: 'Get a bounce regex by its hash', + tags: ['bounces'], + 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: 'regexHash', + description: 'Regex hash', + 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/BounceRegex') + ), + 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 getOne(Request $request, string $regexHash): JsonResponse + { + $this->requireAuthentication($request); + $entity = $this->manager->getByHash($regexHash); + if (!$entity) { + throw $this->createNotFoundException('Bounce regex not found.'); + } + + return $this->json($this->normalizer->normalize($entity), Response::HTTP_OK); + } + + #[Route('', name: 'create_or_update', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/bounces/regex', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Creates a new bounce regex or updates an existing one (matched by regex hash).', + summary: 'Create or update a bounce regex', + requestBody: new OA\RequestBody( + description: 'Create or update a bounce regex rule.', + required: true, + content: new OA\JsonContent( + required: ['regex'], + properties: [ + new OA\Property(property: 'regex', type: 'string', example: '/mailbox is full/i'), + new OA\Property(property: 'action', type: 'string', example: 'delete', nullable: true), + new OA\Property(property: 'list_order', type: 'integer', example: 0, nullable: true), + new OA\Property(property: 'admin', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'comment', type: 'string', example: 'Auto-generated', nullable: true), + new OA\Property(property: 'status', type: 'string', example: 'active', nullable: true), + ], + type: 'object' + ) + ), + tags: ['bounces'], + 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/BounceRegex') + ), + 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 createOrUpdate(Request $request): JsonResponse + { + $this->requireAuthentication($request); + /** @var CreateBounceRegexRequest $dto */ + $dto = $this->validator->validate($request, CreateBounceRegexRequest::class); + + $entity = $this->manager->createOrUpdateFromPattern( + regex: $dto->regex, + action: $dto->action, + listOrder: $dto->listOrder, + adminId: $dto->admin, + comment: $dto->comment, + status: $dto->status + ); + $this->entityManager->flush(); + + return $this->json($this->normalizer->normalize($entity), Response::HTTP_CREATED); + } + + #[Route('/{regexHash}', name: 'delete', methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/bounces/regex/{regexHash}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Delete a bounce regex by its hash.', + summary: 'Delete a bounce regex by its hash', + tags: ['bounces'], + 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: 'regexHash', + description: 'Regex hash', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: Response::HTTP_NO_CONTENT, + description: 'Success' + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function delete(Request $request, string $regexHash): JsonResponse + { + $this->requireAuthentication($request); + $entity = $this->manager->getByHash($regexHash); + if (!$entity) { + throw $this->createNotFoundException('Bounce regex not found.'); + } + $this->manager->delete($entity); + $this->entityManager->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index 6ebac37a..b1b8e804 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -4,9 +4,10 @@ namespace PhpList\RestBundle\Messaging\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Service\CampaignProcessor; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; @@ -17,6 +18,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -28,17 +30,18 @@ class CampaignController extends BaseController { private CampaignService $campaignService; - private CampaignProcessor $campaignProcessor; + private MessageBusInterface $messageBus; public function __construct( Authentication $authentication, RequestValidator $validator, CampaignService $campaignService, - CampaignProcessor $campaignProcessor, + MessageBusInterface $messageBus, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->campaignService = $campaignService; - $this->campaignProcessor = $campaignProcessor; + $this->messageBus = $messageBus; } #[Route('', name: 'get_list', methods: ['GET'])] @@ -211,11 +214,10 @@ public function createMessage(Request $request): JsonResponse /** @var CreateMessageRequest $createMessageRequest */ $createMessageRequest = $this->validator->validate($request, CreateMessageRequest::class); + $message = $this->campaignService->createMessage($createMessageRequest, $authUser); + $this->entityManager->flush(); - return $this->json( - $this->campaignService->createMessage($createMessageRequest, $authUser), - Response::HTTP_CREATED - ); + return $this->json(data: $message, status: Response::HTTP_CREATED); } #[Route('/{messageId}', name: 'update', requirements: ['messageId' => '\d+'], methods: ['PUT'])] @@ -284,11 +286,10 @@ public function updateMessage( /** @var UpdateMessageRequest $updateMessageRequest */ $updateMessageRequest = $this->validator->validate($request, UpdateMessageRequest::class); + $message = $this->campaignService->updateMessage($updateMessageRequest, $authUser, $message); + $this->entityManager->flush(); - return $this->json( - $this->campaignService->updateMessage($updateMessageRequest, $authUser, $message), - Response::HTTP_OK - ); + return $this->json(data:$message, status: Response::HTTP_OK); } #[Route('/{messageId}', name: 'delete', requirements: ['messageId' => '\d+'], methods: ['DELETE'])] @@ -339,6 +340,7 @@ public function deleteMessage( $authUser = $this->requireAuthentication($request); $this->campaignService->deleteMessage($authUser, $message); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -388,7 +390,7 @@ public function sendMessage( throw $this->createNotFoundException('Campaign not found.'); } - $this->campaignProcessor->process($message); + $this->messageBus->dispatch(new SyncCampaignProcessorMessage($message->getId())); return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); } diff --git a/src/Messaging/Controller/ListMessageController.php b/src/Messaging/Controller/ListMessageController.php index 0ee3eb9e..f67c8fa9 100644 --- a/src/Messaging/Controller/ListMessageController.php +++ b/src/Messaging/Controller/ListMessageController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Messaging\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager; @@ -37,7 +38,8 @@ public function __construct( ListMessageManager $listMessageManager, ListMessageNormalizer $listMessageNormalizer, SubscriberListNormalizer $subscriberListNormalizer, - MessageNormalizer $messageNormalizer + MessageNormalizer $messageNormalizer, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->listMessageManager = $listMessageManager; @@ -262,6 +264,7 @@ public function associateMessageWithList( } $listMessage = $this->listMessageManager->associateMessageWithList($message, $subscriberList); + $this->entityManager->flush(); return $this->json( data: $this->listMessageNormalizer->normalize($listMessage), diff --git a/src/Messaging/Controller/TemplateController.php b/src/Messaging/Controller/TemplateController.php index 6513db26..bc24a02d 100644 --- a/src/Messaging/Controller/TemplateController.php +++ b/src/Messaging/Controller/TemplateController.php @@ -4,9 +4,10 @@ namespace PhpList\RestBundle\Messaging\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Template; -use PhpList\Core\Domain\Messaging\Service\TemplateManager; +use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; @@ -37,6 +38,7 @@ public function __construct( TemplateNormalizer $normalizer, TemplateManager $templateManager, PaginatedDataProvider $paginatedDataProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->normalizer = $normalizer; @@ -260,9 +262,11 @@ public function createTemplates(Request $request): JsonResponse /** @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($this->templateManager->create($createTemplateRequest->getDto())), + $this->normalizer->normalize($template), Response::HTTP_CREATED ); } @@ -318,6 +322,7 @@ public function delete( } $this->templateManager->delete($template); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Messaging/OpenApi/SwaggerSchemasResponse.php b/src/Messaging/OpenApi/SwaggerSchemasResponse.php index a14e9b58..9e4cfb55 100644 --- a/src/Messaging/OpenApi/SwaggerSchemasResponse.php +++ b/src/Messaging/OpenApi/SwaggerSchemasResponse.php @@ -120,6 +120,21 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'BounceRegex', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 10), + new OA\Property(property: 'regex', type: 'string', example: '/mailbox is full/i'), + new OA\Property(property: 'regex_hash', type: 'string', example: 'd41d8cd98f00b204e9800998ecf8427e'), + new OA\Property(property: 'action', type: 'string', example: 'delete', nullable: true), + new OA\Property(property: 'list_order', type: 'integer', example: 0, nullable: true), + new OA\Property(property: 'admin_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'comment', type: 'string', example: 'Auto-generated rule', nullable: true), + new OA\Property(property: 'status', type: 'string', example: 'active', nullable: true), + new OA\Property(property: 'count', type: 'integer', example: 5, nullable: true), + ], + type: 'object' +)] class SwaggerSchemasResponse { } diff --git a/src/Messaging/Request/CreateBounceRegexRequest.php b/src/Messaging/Request/CreateBounceRegexRequest.php new file mode 100644 index 00000000..dfbee9a3 --- /dev/null +++ b/src/Messaging/Request/CreateBounceRegexRequest.php @@ -0,0 +1,63 @@ + $this->regex, + 'action' => $this->action, + 'listOrder' => $this->listOrder, + 'admin' => $this->admin, + 'comment' => $this->comment, + 'status' => $this->status, + ]; + } + + #[Assert\Callback('validateRegexPattern')] + public function validateRegexPattern(ExecutionContextInterface $context): void + { + if (!isset($this->regex)) { + return; + } + set_error_handler(static function () { + return true; + }); + // phpcs:ignore Generic.PHP.NoSilencedErrors + $allGood = @preg_match($this->regex, ''); + restore_error_handler(); + + if ($allGood === false) { + $context->buildViolation('Invalid regular expression pattern.') + ->atPath('regex') + ->addViolation(); + } + } +} diff --git a/src/Messaging/Request/CreateTemplateRequest.php b/src/Messaging/Request/CreateTemplateRequest.php index c5bbbcb8..9ab4c6c8 100644 --- a/src/Messaging/Request/CreateTemplateRequest.php +++ b/src/Messaging/Request/CreateTemplateRequest.php @@ -6,20 +6,21 @@ use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto; use PhpList\RestBundle\Common\Request\RequestInterface; +use PhpList\RestBundle\Messaging\Validator\Constraint\ContainsPlaceholder; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Validator\Constraints as Assert; class CreateTemplateRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\NotNull] public string $title; #[Assert\NotBlank] - #[\PhpList\RestBundle\Messaging\Validator\Constraint\ContainsPlaceholder] + #[ContainsPlaceholder] public string $content; - #[\PhpList\RestBundle\Messaging\Validator\Constraint\ContainsPlaceholder] + #[ContainsPlaceholder] public ?string $text = null; public ?UploadedFile $file = null; diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php index ca908e62..03fd3323 100644 --- a/src/Messaging/Request/Message/MessageMetadataRequest.php +++ b/src/Messaging/Request/Message/MessageMetadataRequest.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Messaging\Request\Message; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageMetadataDto; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use Symfony\Component\Validator\Constraints as Assert; class MessageMetadataRequest implements RequestDtoInterface @@ -12,10 +13,13 @@ class MessageMetadataRequest implements RequestDtoInterface #[Assert\NotBlank] public string $status; + /** + * @SuppressWarnings(PHPMD.StaticAccess) + */ public function getDto(): MessageMetadataDto { return new MessageMetadataDto( - status: $this->status, + status: MessageStatus::from($this->status), ); } } diff --git a/src/Messaging/Serializer/BounceRegexNormalizer.php b/src/Messaging/Serializer/BounceRegexNormalizer.php new file mode 100644 index 00000000..5771bd8b --- /dev/null +++ b/src/Messaging/Serializer/BounceRegexNormalizer.php @@ -0,0 +1,41 @@ + $object->getId(), + 'regex' => $object->getRegex(), + 'regex_hash' => $object->getRegexHash(), + 'action' => $object->getAction(), + 'list_order' => $object->getListOrder(), + 'admin_id' => $object->getAdminId(), + 'comment' => $object->getComment(), + 'status' => $object->getStatus(), + 'count' => $object->getCount(), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof BounceRegex; + } +} diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php index b659b3d6..dcad6358 100644 --- a/src/Messaging/Serializer/MessageNormalizer.php +++ b/src/Messaging/Serializer/MessageNormalizer.php @@ -39,7 +39,7 @@ public function normalize($object, string $format = null, array $context = []): 'format_options' => $object->getFormat()->getFormatOptions() ], 'message_metadata' => [ - 'status' => $object->getMetadata()->getStatus(), + 'status' => $object->getMetadata()->getStatus()->value, 'processed' => $object->getMetadata()->isProcessed(), 'views' => $object->getMetadata()->getViews(), 'bounce_count' => $object->getMetadata()->getBounceCount(), diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index b6680d1e..4bc9e3c6 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -4,11 +4,12 @@ namespace PhpList\RestBundle\Messaging\Service; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Service\MessageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Messaging\Request\CreateMessageRequest; use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest; @@ -23,6 +24,7 @@ public function __construct( private readonly MessageManager $messageManager, private readonly PaginatedDataProvider $paginatedProvider, private readonly MessageNormalizer $normalizer, + private readonly EntityManagerInterface $entityManager, ) { } @@ -86,5 +88,6 @@ public function deleteMessage(Administrator $administrator, Message $message = n } $this->messageManager->delete($message); + $this->entityManager->flush(); } } diff --git a/src/Subscription/Controller/BlacklistController.php b/src/Subscription/Controller/BlacklistController.php new file mode 100644 index 00000000..9b4e9580 --- /dev/null +++ b/src/Subscription/Controller/BlacklistController.php @@ -0,0 +1,288 @@ +authentication = $authentication; + $this->blacklistManager = $blacklistManager; + $this->normalizer = $normalizer; + } + + #[Route('/check/{email}', name: 'check', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/blacklist/check/{email}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Check if email is blacklisted', + tags: ['blacklist'], + 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: 'email', + description: 'Email address to check', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'blacklisted', type: 'boolean'), + new OA\Property(property: 'reason', type: 'string', nullable: true) + ] + ), + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + ] + )] + 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.'); + } + + $isBlacklisted = $this->blacklistManager->isEmailBlacklisted($email); + $reason = $isBlacklisted ? $this->blacklistManager->getBlacklistReason($email) : null; + + return $this->json([ + 'blacklisted' => $isBlacklisted, + 'reason' => $reason, + ]); + } + + #[Route('/add', name: 'add', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/blacklist/add', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Adds an email address to the blacklist.', + requestBody: new OA\RequestBody( + description: 'Email to blacklist', + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'email', type: 'string'), + new OA\Property(property: 'reason', type: 'string', nullable: true) + ] + ) + ), + tags: ['blacklist'], + 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( + properties: [ + new OA\Property(property: 'success', type: 'boolean'), + new OA\Property(property: 'message', type: 'string') + ] + ), + ), + 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 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.'); + } + + /** @var AddToBlacklistRequest $definitionRequest */ + $definitionRequest = $this->validator->validate($request, AddToBlacklistRequest::class); + + $userBlacklisted = $this->blacklistManager->addEmailToBlacklist( + email: $definitionRequest->email, + reasonData: $definitionRequest->reason + ); + $this->entityManager->flush(); + $json = $this->normalizer->normalize($userBlacklisted, 'json'); + + return $this->json($json, Response::HTTP_CREATED); + } + + #[Route('/remove/{email}', name: 'remove', methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/blacklist/remove/{email}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Removes an email address from the blacklist.', + tags: ['blacklist'], + 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: 'email', + description: 'Email address to remove from blacklist', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean'), + new OA\Property(property: 'message', type: 'string') + ] + ), + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + ] + )] + 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->blacklistManager->removeEmailFromBlacklist($email); + $this->entityManager->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + #[Route('/info/{email}', name: 'info', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/blacklist/info/{email}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Gets detailed information about a blacklisted email.', + tags: ['blacklist'], + 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: 'email', + description: 'Email address to get information for', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'email', type: 'string'), + new OA\Property(property: 'added', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'reason', type: 'string', nullable: true) + ] + ), + ), + 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/BadRequestResponse') + ), + ] + )] + 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.'); + } + + $blacklistInfo = $this->blacklistManager->getBlacklistInfo($email); + if (!$blacklistInfo) { + return $this->json([ + 'error' => sprintf('Email %s is not blacklisted', $email) + ], Response::HTTP_NOT_FOUND); + } + + $reason = $this->blacklistManager->getBlacklistReason($email); + + return $this->json([ + 'email' => $blacklistInfo->getEmail(), + 'added' => $blacklistInfo->getAdded()?->format('c'), + 'reason' => $reason, + ]); + } +} diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php new file mode 100644 index 00000000..ef7a59c3 --- /dev/null +++ b/src/Subscription/Controller/SubscribePageController.php @@ -0,0 +1,439 @@ + '\\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribe-pages/{id}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Get subscribe page', + tags: ['subscriptions'], + 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: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + 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', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function getPage( + 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 view subscribe pages.'); + } + + if (!$page) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + return $this->json($this->normalizer->normalize($page), Response::HTTP_OK); + } + + #[Route('', name: 'create', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/subscribe-pages', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Create subscribe page', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'title', type: 'string'), + new OA\Property(property: 'active', type: 'boolean', nullable: true), + ] + ) + ), + tags: ['subscriptions'], + 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: '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', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ) + ] + )] + 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); + + $page = $this->subscribePageManager->createPage($createRequest->title, $createRequest->active, $admin); + $this->entityManager->flush(); + + return $this->json($this->normalizer->normalize($page), Response::HTTP_CREATED); + } + + #[Route('/{id}', name: 'update', requirements: ['id' => '\\d+'], methods: ['PUT'])] + #[OA\Put( + path: '/api/v2/subscribe-pages/{id}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Update subscribe page', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'title', type: 'string', nullable: true), + new OA\Property(property: 'active', type: 'boolean', nullable: true), + ] + ) + ), + tags: ['subscriptions'], + 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: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + 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', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function updatePage( + 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 update subscribe pages.'); + } + + if (!$page) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + /** @var SubscribePageRequest $updateRequest */ + $updateRequest = $this->validator->validate($request, SubscribePageRequest::class); + + $updated = $this->subscribePageManager->updatePage( + page: $page, + title: $updateRequest->title, + active: $updateRequest->active, + owner: $admin, + ); + $this->entityManager->flush(); + + return $this->json($this->normalizer->normalize($updated), Response::HTTP_OK); + } + + #[Route('/{id}', name: 'delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/subscribe-pages/{id}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Delete subscribe page', + tags: ['subscriptions'], + 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: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + 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', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + 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.'); + } + + if ($page === null) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + $this->subscribePageManager->deletePage($page); + $this->entityManager->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + #[Route('/{id}/data', name: 'get_data', requirements: ['id' => '\\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribe-pages/{id}/data', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Get subscribe page data', + tags: ['subscriptions'], + 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: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + 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: 'name', type: 'string'), + new OA\Property(property: 'data', type: 'string', nullable: true), + ], + 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', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function getPageData( + 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 view subscribe page data.'); + } + + if (!$page) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + $data = $this->subscribePageManager->getPageData($page); + + $json = array_map(static function ($item) { + return [ + 'id' => $item->getId(), + 'name' => $item->getName(), + 'data' => $item->getData(), + ]; + }, $data); + + return $this->json($json, Response::HTTP_OK); + } + + #[Route('/{id}/data', name: 'set_data', requirements: ['id' => '\\d+'], methods: ['PUT'])] + #[OA\Put( + path: '/api/v2/subscribe-pages/{id}/data', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Set subscribe page data item', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'value', type: 'string', nullable: true), + ] + ) + ), + tags: ['subscriptions'], + 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: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'data', type: 'string', nullable: true), + ], + 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', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function setPageData( + 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 update subscribe page data.'); + } + + if (!$page) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + /** @var SubscribePageDataRequest $createRequest */ + $createRequest = $this->validator->validate($request, SubscribePageDataRequest::class); + + $item = $this->subscribePageManager->setPageData($page, $createRequest->name, $createRequest->value); + $this->entityManager->flush(); + + return $this->json([ + 'id' => $item->getId(), + 'name' => $item->getName(), + 'data' => $item->getData(), + ], Response::HTTP_OK); + } +} diff --git a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php index 6d7bad3a..e096552e 100644 --- a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php +++ b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager; @@ -32,7 +33,8 @@ public function __construct( RequestValidator $validator, AttributeDefinitionManager $definitionManager, AttributeDefinitionNormalizer $normalizer, - PaginatedDataProvider $paginatedDataProvider + PaginatedDataProvider $paginatedDataProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->definitionManager = $definitionManager; @@ -87,6 +89,7 @@ public function create(Request $request): JsonResponse $definitionRequest = $this->validator->validate($request, CreateAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->create($definitionRequest->getDto()); + $this->entityManager->flush(); $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -154,6 +157,7 @@ public function update( attributeDefinition: $attributeDefinition, attributeDefinitionDto: $definitionRequest->getDto(), ); + $this->entityManager->flush(); $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_OK); @@ -209,6 +213,7 @@ public function delete( } $this->definitionManager->delete($attributeDefinition); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Subscription/Controller/SubscriberAttributeValueController.php b/src/Subscription/Controller/SubscriberAttributeValueController.php index 834b0977..24b54209 100644 --- a/src/Subscription/Controller/SubscriberAttributeValueController.php +++ b/src/Subscription/Controller/SubscriberAttributeValueController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberAttributeValueFilter; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -33,7 +34,8 @@ public function __construct( RequestValidator $validator, SubscriberAttributeManager $attributeManager, SubscriberAttributeValueNormalizer $normalizer, - PaginatedDataProvider $paginatedDataProvider + PaginatedDataProvider $paginatedDataProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->attributeManager = $attributeManager; @@ -193,6 +195,7 @@ public function delete( throw $this->createNotFoundException('Subscriber attribute not found.'); } $this->attributeManager->delete($attribute); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -349,6 +352,7 @@ public function getAttributeDefinition( } $attribute = $this->attributeManager->getSubscriberAttribute($subscriber->getId(), $definition->getId()); $this->attributeManager->delete($attribute); + $this->entityManager->flush(); return $this->json( $this->normalizer->normalize($attribute), diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index fc7be66b..e144e203 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -4,19 +4,23 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Subscription\Request\CreateSubscriberRequest; use PhpList\RestBundle\Subscription\Request\UpdateSubscriberRequest; -use PhpList\RestBundle\Subscription\Service\SubscriberService; +use PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer; +use PhpList\RestBundle\Subscription\Service\SubscriberHistoryService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -28,16 +32,16 @@ #[Route('/subscribers', name: 'subscriber_')] class SubscriberController extends BaseController { - private SubscriberService $subscriberService; - public function __construct( Authentication $authentication, RequestValidator $validator, - SubscriberService $subscriberService, + private readonly SubscriberManager $subscriberManager, + private readonly SubscriberNormalizer $subscriberNormalizer, + private readonly SubscriberHistoryService $subscriberHistoryService, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->authentication = $authentication; - $this->subscriberService = $subscriberService; } #[Route('', name: 'create', methods: ['POST'])] @@ -93,7 +97,9 @@ public function createSubscriber(Request $request): JsonResponse /** @var CreateSubscriberRequest $subscriberRequest */ $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); - $subscriberData = $this->subscriberService->createSubscriber($subscriberRequest); + $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest->getDto()); + $this->entityManager->flush(); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber, 'json'); return $this->json($subscriberData, Response::HTTP_CREATED); } @@ -163,7 +169,9 @@ public function updateSubscriber( } /** @var UpdateSubscriberRequest $updateSubscriberRequest */ $updateSubscriberRequest = $this->validator->validate($request, UpdateSubscriberRequest::class); - $subscriberData = $this->subscriberService->updateSubscriber($updateSubscriberRequest); + $subscriber = $this->subscriberManager->updateSubscriber($updateSubscriberRequest->getDto(), $admin); + $this->entityManager->flush(); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber, 'json'); return $this->json($subscriberData, Response::HTTP_OK); } @@ -213,7 +221,8 @@ public function getSubscriber(Request $request, int $subscriberId): JsonResponse { $this->requireAuthentication($request); - $subscriberData = $this->subscriberService->getSubscriber($subscriberId); + $subscriber = $this->subscriberManager->getSubscriberById($subscriberId); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber); return $this->json($subscriberData, Response::HTTP_OK); } @@ -309,7 +318,7 @@ public function getSubscriberHistory( ): JsonResponse { $this->requireAuthentication($request); - $historyData = $this->subscriberService->getSubscriberHistory($request, $subscriber); + $historyData = $this->subscriberHistoryService->getSubscriberHistory($request, $subscriber); return $this->json( data: $historyData, @@ -370,11 +379,82 @@ public function deleteSubscriber( if (!$subscriber) { throw $this->createNotFoundException('Subscriber not found.'); } - $this->subscriberService->deleteSubscriber($subscriber); + $this->subscriberManager->deleteSubscriber($subscriber); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } + #[Route( + '/{subscriberId}/reset-bounce-count', + name: 'reset_bounce_count', + requirements: ['subscriberId' => '\d+'], + methods: ['POST'] + )] + #[OA\Post( + path: '/api/v2/subscribers/{subscriberId}/reset-bounce-count', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Reset bounce count for a subscriber.', + tags: ['subscribers'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'subscriberId', + description: 'Subscriber ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Subscriber'), + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function resetBounceCount( + Request $request, + #[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.'); + } + + $subscriber = $this->subscriberManager->resetBounceCount($subscriber); + $this->entityManager->flush(); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber, 'json'); + + return $this->json($subscriberData, Response::HTTP_OK); + } + #[Route('/confirm', name: 'confirm', methods: ['GET'])] #[OA\Get( path: '/api/v2/subscribers/confirm', @@ -416,9 +496,10 @@ public function setSubscriberAsConfirmed(Request $request): Response return new Response('

Missing confirmation code.

', 400); } - $subscriber = $this->subscriberService->confirmSubscriber($uniqueId); - - if (!$subscriber) { + try { + $this->subscriberManager->markAsConfirmedByUniqueId($uniqueId); + $this->entityManager->flush(); + } catch (NotFoundHttpException) { return new Response('

Subscriber isn\'t found or already confirmed.

', 404); } diff --git a/src/Subscription/Controller/SubscriberImportController.php b/src/Subscription/Controller/SubscriberImportController.php index d084116a..1e4983fc 100644 --- a/src/Subscription/Controller/SubscriberImportController.php +++ b/src/Subscription/Controller/SubscriberImportController.php @@ -7,6 +7,7 @@ use Exception; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; +use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter; use PhpList\Core\Security\Authentication; @@ -144,6 +145,10 @@ public function importSubscribers(Request $request): JsonResponse 'skipped' => $stats['skipped'], 'errors' => $stats['errors'] ]); + } catch (CouldNotReadUploadedFileException $exception) { + return $this->json([ + 'message' => 'Could not read uploaded file. ' . $exception->getMessage() + ], Response::HTTP_BAD_REQUEST); } catch (Exception $e) { return $this->json([ 'message' => $e->getMessage() diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index a0ea079f..304c343f 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager; @@ -32,6 +33,7 @@ class SubscriberListController extends BaseController private SubscriberListNormalizer $normalizer; private SubscriberListManager $subscriberListManager; private PaginatedDataProvider $paginatedDataProvider; + private EntityManagerInterface $entityManager; public function __construct( Authentication $authentication, @@ -39,11 +41,13 @@ public function __construct( SubscriberListNormalizer $normalizer, SubscriberListManager $subscriberListManager, PaginatedDataProvider $paginatedDataProvider, + EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->normalizer = $normalizer; $this->subscriberListManager = $subscriberListManager; $this->paginatedDataProvider = $paginatedDataProvider; + $this->entityManager = $entityManager; } #[Route('', name: 'get_list', methods: ['GET'])] @@ -223,6 +227,7 @@ public function deleteList( } $this->subscriberListManager->delete($list); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -273,6 +278,7 @@ public function createList(Request $request, SubscriberListNormalizer $normalize /** @var CreateSubscriberListRequest $subscriberListRequest */ $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest->getDto(), $authUser); + $this->entityManager->flush(); return $this->json($normalizer->normalize($data), Response::HTTP_CREATED); } diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index b8bf72d0..a7fffba3 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; @@ -28,16 +29,19 @@ class SubscriptionController extends BaseController { private SubscriptionManager $subscriptionManager; private SubscriptionNormalizer $subscriptionNormalizer; + private EntityManagerInterface $entityManager; public function __construct( Authentication $authentication, RequestValidator $validator, SubscriptionManager $subscriptionManager, SubscriptionNormalizer $subscriptionNormalizer, + EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->subscriptionManager = $subscriptionManager; $this->subscriptionNormalizer = $subscriptionNormalizer; + $this->entityManager = $entityManager; } #[Route('/{listId}/subscribers', name: 'create', requirements: ['listId' => '\d+'], methods: ['POST'])] @@ -127,6 +131,7 @@ public function createSubscription( /** @var SubscriptionRequest $subscriptionRequest */ $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); + $this->entityManager->flush(); $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); return $this->json($normalized, Response::HTTP_CREATED); @@ -193,6 +198,7 @@ public function deleteSubscriptions( /** @var SubscriptionRequest $subscriptionRequest */ $subscriptionRequest = $this->validator->validateDto($subscriptionRequest); $this->subscriptionManager->deleteSubscriptions($list, $subscriptionRequest->emails); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Subscription/OpenApi/SwaggerSchemasResponse.php b/src/Subscription/OpenApi/SwaggerSchemasResponse.php index 83764956..ac7eedfb 100644 --- a/src/Subscription/OpenApi/SwaggerSchemasResponse.php +++ b/src/Subscription/OpenApi/SwaggerSchemasResponse.php @@ -142,6 +142,15 @@ ), ], )] +#[OA\Schema( + schema: 'SubscribePage', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'title', type: 'string', example: 'Subscribe to our newsletter'), + new OA\Property(property: 'active', type: 'boolean', example: true), + new OA\Property(property: 'owner', ref: '#/components/schemas/Administrator'), + ], +)] class SwaggerSchemasResponse { } diff --git a/src/Subscription/Request/AddToBlacklistRequest.php b/src/Subscription/Request/AddToBlacklistRequest.php new file mode 100644 index 00000000..68dc81e7 --- /dev/null +++ b/src/Subscription/Request/AddToBlacklistRequest.php @@ -0,0 +1,23 @@ + $object->getId(), + 'title' => $object->getTitle(), + 'active' => $object->isActive(), + 'owner' => $this->adminNormalizer->normalize($object->getOwner()), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof SubscribePage; + } +} diff --git a/src/Subscription/Serializer/UserBlacklistNormalizer.php b/src/Subscription/Serializer/UserBlacklistNormalizer.php new file mode 100644 index 00000000..f8ff01c0 --- /dev/null +++ b/src/Subscription/Serializer/UserBlacklistNormalizer.php @@ -0,0 +1,42 @@ +blacklistManager->getBlacklistReason($object->getEmail()); + + return [ + 'email' => $object->getEmail(), + 'added' => $object->getAdded()?->format('Y-m-d\TH:i:sP'), + 'reason' => $reason, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof UserBlacklist; + } +} diff --git a/src/Subscription/Service/SubscriberService.php b/src/Subscription/Service/SubscriberService.php deleted file mode 100644 index 4a8fa97a..00000000 --- a/src/Subscription/Service/SubscriberService.php +++ /dev/null @@ -1,64 +0,0 @@ -subscriberManager->createSubscriber($subscriberRequest->getDto()); - return $this->subscriberNormalizer->normalize($subscriber, 'json'); - } - - public function updateSubscriber(UpdateSubscriberRequest $updateSubscriberRequest): array - { - $subscriber = $this->subscriberManager->updateSubscriber($updateSubscriberRequest->getDto()); - return $this->subscriberNormalizer->normalize($subscriber, 'json'); - } - - public function getSubscriber(int $subscriberId): array - { - $subscriber = $this->subscriberManager->getSubscriber($subscriberId); - return $this->subscriberNormalizer->normalize($subscriber); - } - - public function getSubscriberHistory(Request $request, ?Subscriber $subscriber): array - { - return $this->subscriberHistoryService->getSubscriberHistory($request, $subscriber); - } - - public function deleteSubscriber(Subscriber $subscriber): void - { - $this->subscriberManager->deleteSubscriber($subscriber); - } - - public function confirmSubscriber(string $uniqueId): ?Subscriber - { - if (!$uniqueId) { - return null; - } - - try { - return $this->subscriberManager->markAsConfirmedByUniqueId($uniqueId); - } catch (NotFoundHttpException) { - return null; - } - } -} diff --git a/tests/Integration/Identity/Controller/SessionControllerTest.php b/tests/Integration/Identity/Controller/SessionControllerTest.php index 41f3298a..71a811c6 100644 --- a/tests/Integration/Identity/Controller/SessionControllerTest.php +++ b/tests/Integration/Identity/Controller/SessionControllerTest.php @@ -100,7 +100,7 @@ public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() { $this->loadFixtures([AdministratorFixture::class]); - $loginName = 'john.doe'; + $loginName = 'john.doe.1'; $password = 'a sandwich and a cup of coffee'; $jsonData = ['login_name' => $loginName, 'password' => $password]; diff --git a/tests/Integration/Identity/Fixtures/AdministratorFixture.php b/tests/Integration/Identity/Fixtures/AdministratorFixture.php index aa8790f8..0bf1b316 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorFixture.php @@ -29,6 +29,8 @@ public function load(ObjectManager $manager): void $headers = fgetcsv($handle); + $adminRepository = $manager->getRepository(Administrator::class); + do { $data = fgetcsv($handle); if ($data === false) { @@ -36,6 +38,12 @@ public function load(ObjectManager $manager): void } $row = array_combine($headers, $data); + // Make fixture idempotent: skip if admin with this ID already exists + $existing = $adminRepository->find($row['id']); + if ($existing instanceof Administrator) { + continue; + } + $admin = new Administrator(); $this->setSubjectId($admin, (int)$row['id']); $admin->setLoginName($row['loginname']); diff --git a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php index 3a47138f..b0d2eb6c 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php @@ -42,14 +42,15 @@ public function load(ObjectManager $manager): void if ($admin === null) { $admin = new Administrator(); $this->setSubjectId($admin, (int)$row['adminid']); + // Use a deterministic, non-conflicting login name to avoid clashes with other fixtures + $admin->setLoginName('admin_' . $row['adminid']); $admin->setSuperUser(true); $manager->persist($admin); } - $adminToken = new AdministratorToken(); + $adminToken = new AdministratorToken($admin); $this->setSubjectId($adminToken, (int)$row['id']); $adminToken->setKey($row['value']); - $adminToken->setAdministrator($admin); $manager->persist($adminToken); $this->setSubjectProperty($adminToken, 'expiry', new DateTime($row['expires'])); diff --git a/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php b/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php new file mode 100644 index 00000000..4c7872e4 --- /dev/null +++ b/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php @@ -0,0 +1,81 @@ +get(BounceRegexController::class)); + } + + public function testListWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/bounces/regex'); + $this->assertHttpForbidden(); + } + + public function testListWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/bounces/regex', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testListWithValidSessionKeyReturnsOkay(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex'); + $this->assertHttpOkay(); + } + + public function testCreateGetDeleteFlow(): void + { + $payload = json_encode([ + 'regex' => '/mailbox is full/i', + 'action' => 'delete', + 'list_order' => 0, + 'admin' => 1, + 'comment' => 'Auto-generated rule', + 'status' => 'active', + ]); + + $this->authenticatedJsonRequest('POST', '/api/v2/bounces/regex', [], [], [], $payload); + $this->assertHttpCreated(); + $created = $this->getDecodedJsonResponseContent(); + $this->assertSame('/mailbox is full/i', $created['regex']); + $this->assertSame(md5('/mailbox is full/i'), $created['regex_hash']); + + $hash = $created['regex_hash']; + $this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex/' . $hash); + $this->assertHttpOkay(); + $one = $this->getDecodedJsonResponseContent(); + $this->assertSame($hash, $one['regex_hash']); + + $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/' . $hash); + $this->assertHttpNoContent(); + + $this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex/' . $hash); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Messaging/Controller/CampaignControllerTest.php b/tests/Integration/Messaging/Controller/CampaignControllerTest.php index a47fe02b..fcbf46f0 100644 --- a/tests/Integration/Messaging/Controller/CampaignControllerTest.php +++ b/tests/Integration/Messaging/Controller/CampaignControllerTest.php @@ -98,11 +98,11 @@ public function testSendMessageWithValidSessionReturnsOkay(): void { $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/1/send'); + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/send'); $this->assertHttpOkay(); $response = $this->getDecodedJsonResponseContent(); - self::assertSame(1, $response['id']); + self::assertSame(2, $response['id']); } public function testSendMessageWithInvalidIdReturnsNotFound(): void diff --git a/tests/Integration/Messaging/Fixtures/Message.csv b/tests/Integration/Messaging/Fixtures/Message.csv index c2e52a79..6e831060 100644 --- a/tests/Integration/Messaging/Fixtures/Message.csv +++ b/tests/Integration/Messaging/Fixtures/Message.csv @@ -19,3 +19,23 @@ id,uuid,subject,fromfield,tofield,replyto,message,textmessage,footer,entered,mod ",2024-11-10 16:57:46,2024-11-14 08:32:15,2024-11-14 08:32:00,0,2024-11-14 08:32:00,0,2024-11-14 08:32:00,sent,,2024-11-14 08:32:15,1,invite,0,0,0,0,0,0,0,0,0,2024-11-14 08:32:15,,1 +2,2df6b147-8470-45ed-8e4e-86aa01af400f,Do you want to continue receiving our messages?, My Name ,"","","

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

+ +

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

+ +

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

+ +

Update preferences

+ +

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

+ +

Thank you!

","","-- + +
+

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

+

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

+
+ + ",2024-11-10 16:57:46,2024-11-14 08:32:15,2024-11-14 08:32:00,0,2024-11-14 08:32:00,0,2024-11-14 08:32:00,submitted,,2024-11-14 08:32:15,1,invite,0,0,0,0,0,0,0,0,0,2024-11-14 08:32:15,,1 diff --git a/tests/Integration/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Messaging/Fixtures/MessageFixture.php index 3e6b4a99..42b33b51 100644 --- a/tests/Integration/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Messaging/Fixtures/MessageFixture.php @@ -45,6 +45,7 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + $admin = $adminRepository->find($row['owner']); $template = $templateRepository->find($row['template']); @@ -59,43 +60,43 @@ public function load(ObjectManager $manager): void ); $schedule = new MessageSchedule( - (int)$row['repeatinterval'], - new DateTime($row['repeatuntil']), - (int)$row['requeueinterval'], - new DateTime($row['requeueuntil']), - new DateTime($row['embargo']), + repeatInterval: (int)$row['repeatinterval'], + repeatUntil: new DateTime($row['repeatuntil']), + requeueInterval: (int)$row['requeueinterval'], + requeueUntil: new DateTime($row['requeueuntil']), + embargo: new DateTime($row['embargo']), ); $metadata = new MessageMetadata( - $row['status'], - (int)$row['bouncecount'], - new DateTime($row['entered']), - new DateTime($row['sent']), - new DateTime($row['sendstart']), + status: Message\MessageStatus::from($row['status']), + bounceCount: (int)$row['bouncecount'], + entered: new DateTime($row['entered']), + sent: new DateTime($row['sent']), + sendStart: new DateTime($row['sendstart']), ); $metadata->setProcessed((bool) $row['processed']); $metadata->setViews((int)$row['viewed']); $content = new MessageContent( - $row['subject'], - $row['message'], - $row['textmessage'], - $row['footer'] + subject: $row['subject'], + text: $row['message'], + textMessage: $row['textmessage'], + footer: $row['footer'] ); $options = new MessageOptions( - $row['fromfield'], - $row['tofield'], - $row['replyto'], - $row['userselection'], - $row['rsstemplate'], + fromField: $row['fromfield'], + toField: $row['tofield'], + replyTo: $row['replyto'], + userSelection: $row['userselection'], + rssTemplate: $row['rsstemplate'], ); $message = new Message( - $format, - $schedule, - $metadata, - $content, - $options, - $admin, - $template, + format: $format, + schedule: $schedule, + metadata: $metadata, + content: $content, + options: $options, + owner: $admin, + template: $template, ); $this->setSubjectId($message, (int)$row['id']); $this->setSubjectProperty($message, 'uuid', $row['uuid']); diff --git a/tests/Integration/Subscription/Controller/BlacklistControllerTest.php b/tests/Integration/Subscription/Controller/BlacklistControllerTest.php new file mode 100644 index 00000000..fdba52ae --- /dev/null +++ b/tests/Integration/Subscription/Controller/BlacklistControllerTest.php @@ -0,0 +1,60 @@ +get(BlacklistController::class) + ); + } + + public function testCheckEmailBlacklistedWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('get', '/api/v2/blacklist/check/test@example.com'); + + $this->assertHttpForbidden(); + } + + public function testAddEmailToBlacklistWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('post', '/api/v2/blacklist/add'); + + $this->assertHttpForbidden(); + } + + public function testAddEmailToBlacklistWithMissingEmailReturnsUnprocessableEntityStatus(): void + { + $jsonData = []; + + $this->authenticatedJsonRequest('post', '/api/v2/blacklist/add', [], [], [], json_encode($jsonData)); + + $this->assertHttpUnprocessableEntity(); + } + + public function testRemoveEmailFromBlacklistWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('delete', '/api/v2/blacklist/remove/test@example.com'); + + $this->assertHttpForbidden(); + } + + public function testGetBlacklistInfoWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('get', '/api/v2/blacklist/info/test@example.com'); + + $this->assertHttpForbidden(); + } +} diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php new file mode 100644 index 00000000..fa2d541a --- /dev/null +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -0,0 +1,292 @@ +get(SubscribePageController::class) + ); + } + + public function testGetSubscribePageWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + + self::getClient()->request('GET', '/api/v2/subscribe-pages/1'); + $this->assertHttpForbidden(); + } + + public function testGetSubscribePageWithSessionReturnsPage(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + + $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/1'); + + $this->assertHttpOkay(); + $data = $this->getDecodedJsonResponseContent(); + + self::assertSame(1, $data['id']); + self::assertSame('Welcome Page', $data['title']); + self::assertTrue($data['active']); + self::assertIsArray($data['owner']); + self::assertSame(1, $data['owner']['id']); + self::assertArrayHasKey('login_name', $data['owner']); + self::assertArrayHasKey('email', $data['owner']); + self::assertArrayHasKey('privileges', $data['owner']); + } + + public function testGetSubscribePageWithSessionNotFound(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + + $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/9999'); + + $this->assertHttpNotFound(); + } + + public function testCreateSubscribePageWithoutSessionReturnsForbidden(): void + { + // no auth fixtures loaded here + $payload = json_encode([ + 'title' => 'new-page@example.org', + 'active' => true, + ], JSON_THROW_ON_ERROR); + + $this->jsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); + + $this->assertHttpForbidden(); + } + + public function testCreateSubscribePageWithSessionCreatesPage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + $payload = json_encode([ + 'title' => 'new-page@example.org', + 'active' => true, + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); + + $this->assertHttpCreated(); + $data = $this->getDecodedJsonResponseContent(); + + self::assertArrayHasKey('id', $data); + self::assertIsInt($data['id']); + self::assertGreaterThanOrEqual(1, $data['id']); + self::assertSame('new-page@example.org', $data['title']); + self::assertTrue($data['active']); + self::assertIsArray($data['owner']); + self::assertArrayHasKey('id', $data['owner']); + self::assertArrayHasKey('login_name', $data['owner']); + self::assertArrayHasKey('email', $data['owner']); + self::assertArrayHasKey('privileges', $data['owner']); + } + + public function testUpdateSubscribePageWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + $payload = json_encode([ + 'title' => 'updated-page@example.org', + 'active' => false, + ], JSON_THROW_ON_ERROR); + + $this->jsonRequest('PUT', '/api/v2/subscribe-pages/1', content: $payload); + $this->assertHttpForbidden(); + } + + public function testUpdateSubscribePageWithSessionReturnsOk(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + $payload = json_encode([ + 'title' => 'updated-page@example.org', + 'active' => false, + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1', content: $payload); + + $this->assertHttpOkay(); + $data = $this->getDecodedJsonResponseContent(); + self::assertSame(1, $data['id']); + self::assertSame('updated-page@example.org', $data['title']); + self::assertFalse($data['active']); + self::assertIsArray($data['owner']); + } + + public function testUpdateSubscribePageWithSessionNotFound(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + $payload = json_encode([ + 'title' => 'updated-page@example.org', + 'active' => false, + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/9999', content: $payload); + $this->assertHttpNotFound(); + } + + public function testDeleteSubscribePageWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + $this->jsonRequest('DELETE', '/api/v2/subscribe-pages/1'); + $this->assertHttpForbidden(); + } + + public function testDeleteSubscribePageWithSessionReturnsNoContentAndRemovesResource(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/subscribe-pages/1'); + $this->assertHttpNoContent(); + + $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/1'); + $this->assertHttpNotFound(); + } + + public function testDeleteSubscribePageWithSessionNotFound(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/subscribe-pages/9999'); + $this->assertHttpNotFound(); + } + + public function testGetSubscribePageDataWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + $this->jsonRequest('GET', '/api/v2/subscribe-pages/1/data'); + $this->assertHttpForbidden(); + } + + public function testGetSubscribePageDataWithSessionReturnsArray(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + + $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/1/data'); + $this->assertHttpOkay(); + $data = $this->getDecodedJsonResponseContent(); + self::assertIsArray($data); + + if (!empty($data)) { + self::assertArrayHasKey('id', $data[0]); + self::assertArrayHasKey('name', $data[0]); + self::assertArrayHasKey('data', $data[0]); + } + } + + public function testGetSubscribePageDataWithSessionNotFound(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + + $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/9999/data'); + $this->assertHttpNotFound(); + } + + public function testSetSubscribePageDataWithoutSessionReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + $payload = json_encode([ + 'name' => 'intro_text', + 'value' => 'Hello world', + ], JSON_THROW_ON_ERROR); + + $this->jsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); + $this->assertHttpForbidden(); + } + + public function testSetSubscribePageDataWithMissingNameReturnsUnprocessableEntity(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + $payload = json_encode([ + 'value' => 'Hello world', + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); + $this->assertHttpUnprocessableEntity(); + } + + public function testSetSubscribePageDataWithSessionReturnsOk(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + $payload = json_encode([ + 'name' => 'intro_text', + 'value' => 'Hello world', + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); + $this->assertHttpOkay(); + $data = $this->getDecodedJsonResponseContent(); + self::assertArrayHasKey('id', $data); + self::assertArrayHasKey('name', $data); + self::assertArrayHasKey('data', $data); + self::assertSame('intro_text', $data['name']); + self::assertSame('Hello world', $data['data']); + } + + public function testSetSubscribePageDataWithSessionNotFound(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + $payload = json_encode([ + 'name' => 'intro_text', + 'value' => 'Hello world', + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/9999/data', content: $payload); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php index 3ffb4dd2..93cf93d0 100644 --- a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php @@ -72,6 +72,8 @@ public function testPostSubscribersWithValidSessionKeyAndMinimalValidDataReturns self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $responseContent['unique_id']); } + + public function testPostSubscribersWithValidSessionKeyAndValidDataCreatesSubscriber() { $email = 'subscriber@example.com'; diff --git a/tests/Integration/Subscription/Fixtures/SubscribePage.csv b/tests/Integration/Subscription/Fixtures/SubscribePage.csv new file mode 100644 index 00000000..6279b9c4 --- /dev/null +++ b/tests/Integration/Subscription/Fixtures/SubscribePage.csv @@ -0,0 +1,3 @@ +id,title,active,owner +1,"Welcome Page",1,1 +2,"Inactive Page",0,1 diff --git a/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php b/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php new file mode 100644 index 00000000..c0cf3496 --- /dev/null +++ b/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php @@ -0,0 +1,62 @@ +getRepository(Administrator::class); + + do { + $data = fgetcsv($handle); + if ($data === false) { + break; + } + $row = array_combine($headers, $data); + + $owner = $adminRepository->find($row['owner']); + if ($owner === null) { + $owner = new Administrator(); + $this->setSubjectId($owner, (int)$row['owner']); + $owner->setSuperUser(true); + $owner->setDisabled(false); + $manager->persist($owner); + } + + $page = new SubscribePage(); + $this->setSubjectId($page, (int)$row['id']); + $page->setTitle($row['title']); + $page->setActive(filter_var($row['active'], FILTER_VALIDATE_BOOLEAN)); + $page->setOwner($owner); + + $manager->persist($page); + } while (true); + + fclose($handle); + } +} diff --git a/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php index c1c4d110..3875e045 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php @@ -6,13 +6,15 @@ use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; +use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture; use RuntimeException; -class SubscriberListFixture extends Fixture +class SubscriberListFixture extends Fixture implements DependentFixtureInterface { use ModelTestTrait; public function load(ObjectManager $manager): void @@ -31,6 +33,7 @@ public function load(ObjectManager $manager): void $headers = fgetcsv($handle); $adminRepository = $manager->getRepository(Administrator::class); + $adminsById = []; do { $data = fgetcsv($handle); @@ -39,13 +42,17 @@ public function load(ObjectManager $manager): void } $row = array_combine($headers, $data); - $admin = $adminRepository->find($row['owner']); + $ownerId = (int)$row['owner']; + $admin = $adminsById[$ownerId] ?? $adminRepository->find($ownerId); if ($admin === null) { $admin = new Administrator(); - $this->setSubjectId($admin, (int)$row['owner']); + $this->setSubjectId($admin, $ownerId); + // Use a deterministic, non-conflicting login name to avoid clashes with other fixtures + $admin->setLoginName('owner_' . $ownerId); $admin->setSuperUser(true); $admin->setDisabled(false); $manager->persist($admin); + $adminsById[$ownerId] = $admin; } $subscriberList = new SubscriberList(); @@ -66,4 +73,11 @@ public function load(ObjectManager $manager): void fclose($handle); } + + public function getDependencies(): array + { + return [ + AdministratorFixture::class, + ]; + } } diff --git a/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php new file mode 100644 index 00000000..03b32d8f --- /dev/null +++ b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php @@ -0,0 +1,80 @@ +regex = '/mailbox is full/i'; + $req->action = 'delete'; + $req->listOrder = 3; + $req->admin = 9; + $req->comment = 'Auto'; + $req->status = 'active'; + + $dto = $req->getDto(); + + $this->assertSame('/mailbox is full/i', $dto['regex']); + $this->assertSame('delete', $dto['action']); + $this->assertSame(3, $dto['listOrder']); + $this->assertSame(9, $dto['admin']); + $this->assertSame('Auto', $dto['comment']); + $this->assertSame('active', $dto['status']); + } + + public function testGetDtoWithDefaults(): void + { + $req = new CreateBounceRegexRequest(); + $req->regex = '/some/i'; + + $dto = $req->getDto(); + + $this->assertSame('/some/i', $dto['regex']); + $this->assertNull($dto['action']); + $this->assertSame(0, $dto['listOrder']); + $this->assertNull($dto['admin']); + $this->assertNull($dto['comment']); + $this->assertNull($dto['status']); + } + + public function testValidateRegexPatternWithValidRegexDoesNotAddViolation(): void + { + $req = new CreateBounceRegexRequest(); + $req->regex = '/valid.*/i'; + + $context = $this->createMock(ExecutionContextInterface::class); + $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 + { + $req = new CreateBounceRegexRequest(); + $req->regex = '/[invalid'; + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('atPath')->with('regex')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->with('Invalid regular expression pattern.') + ->willReturn($builder); + + $req->validateRegexPattern($context); + } +} diff --git a/tests/Unit/Messaging/Serializer/BounceRegexNormalizerTest.php b/tests/Unit/Messaging/Serializer/BounceRegexNormalizerTest.php new file mode 100644 index 00000000..a86b0657 --- /dev/null +++ b/tests/Unit/Messaging/Serializer/BounceRegexNormalizerTest.php @@ -0,0 +1,60 @@ +normalizer = new BounceRegexNormalizer(); + } + + public function testSupportsNormalization(): void + { + $regex = new BounceRegex(); + $this->assertTrue($this->normalizer->supportsNormalization($regex)); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $regexPattern = '/mailbox is full/i'; + $hash = md5($regexPattern); + + $entity = new BounceRegex( + regex: $regexPattern, + regexHash: $hash, + action: 'delete', + listOrder: 2, + adminId: 42, + comment: 'Auto-generated rule', + status: 'active', + count: 7 + ); + + $result = $this->normalizer->normalize($entity); + + $this->assertSame($regexPattern, $result['regex']); + $this->assertSame($hash, $result['regex_hash']); + $this->assertSame('delete', $result['action']); + $this->assertSame(2, $result['list_order']); + $this->assertSame(42, $result['admin_id']); + $this->assertSame('Auto-generated rule', $result['comment']); + $this->assertSame('active', $result['status']); + $this->assertSame(7, $result['count']); + $this->assertArrayHasKey('id', $result); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $this->assertSame([], $this->normalizer->normalize(new \stdClass())); + } +} diff --git a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php index df5a33d2..d90ef900 100644 --- a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php +++ b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php @@ -46,7 +46,7 @@ public function testNormalizeReturnsExpectedArray(): void $entered = new DateTime('2025-01-01T10:00:00+00:00'); $sent = new DateTime('2025-01-02T10:00:00+00:00'); - $metadata = new Message\MessageMetadata('draft'); + $metadata = new Message\MessageMetadata(Message\MessageStatus::Draft); $metadata->setProcessed(true); $metadata->setViews(10); $metadata->setBounceCount(3); @@ -80,7 +80,7 @@ public function testNormalizeReturnsExpectedArray(): void $this->assertSame('Test Template', $result['template']['title']); $this->assertSame('Subject', $result['message_content']['subject']); $this->assertSame(['text', 'html'], $result['message_format']['format_options']); - $this->assertSame('draft', $result['message_metadata']['status']); + $this->assertSame(Message\MessageStatus::Draft->value, $result['message_metadata']['status']); $this->assertSame('from@example.com', $result['message_options']['from_field']); } diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php index 5293f0f9..e328fe97 100644 --- a/tests/Unit/Messaging/Service/CampaignServiceTest.php +++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Tests\Unit\Messaging\Service; +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; @@ -11,7 +12,7 @@ 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\Service\MessageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Messaging\Request\CreateMessageRequest; use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest; @@ -37,9 +38,10 @@ protected function setUp(): void $this->normalizer = $this->createMock(MessageNormalizer::class); $this->campaignService = new CampaignService( - $this->messageManager, - $this->paginatedProvider, - $this->normalizer + messageManager: $this->messageManager, + paginatedProvider: $this->paginatedProvider, + normalizer: $this->normalizer, + entityManager: $this->createMock(EntityManagerInterface::class), ); } diff --git a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php new file mode 100644 index 00000000..523e5904 --- /dev/null +++ b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php @@ -0,0 +1,71 @@ +createMock(AdministratorNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer); + + $page = $this->createMock(SubscribePage::class); + + $this->assertTrue($normalizer->supportsNormalization($page)); + $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $owner = $this->createMock(Administrator::class); + + $page = $this->createMock(SubscribePage::class); + $page->method('getId')->willReturn(42); + $page->method('getTitle')->willReturn('welcome@example.org'); + $page->method('isActive')->willReturn(true); + $page->method('getOwner')->willReturn($owner); + + $adminData = [ + 'id' => 7, + 'login_name' => 'admin', + 'email' => 'admin@example.org', + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], + ]; + + $adminNormalizer = $this->createMock(AdministratorNormalizer::class); + $adminNormalizer->method('normalize')->with($owner)->willReturn($adminData); + + $normalizer = new SubscribePageNormalizer($adminNormalizer); + + $expected = [ + 'id' => 42, + 'title' => 'welcome@example.org', + 'active' => true, + 'owner' => $adminData, + ]; + + $this->assertSame($expected, $normalizer->normalize($page)); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $adminNormalizer = $this->createMock(AdministratorNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer); + + $this->assertSame([], $normalizer->normalize(new stdClass())); + } +} diff --git a/tests/Unit/Subscription/Serializer/UserBlacklistNormalizerTest.php b/tests/Unit/Subscription/Serializer/UserBlacklistNormalizerTest.php new file mode 100644 index 00000000..85619b68 --- /dev/null +++ b/tests/Unit/Subscription/Serializer/UserBlacklistNormalizerTest.php @@ -0,0 +1,57 @@ +createMock(SubscriberBlacklistManager::class); + $normalizer = new UserBlacklistNormalizer($blacklistManager); + $userBlacklist = $this->createMock(UserBlacklist::class); + + $this->assertTrue($normalizer->supportsNormalization($userBlacklist)); + $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testNormalize(): void + { + $email = 'test@example.com'; + $added = new DateTime('2025-08-08T12:00:00+00:00'); + $reason = 'Unsubscribed by user'; + + $userBlacklist = $this->createMock(UserBlacklist::class); + $userBlacklist->method('getEmail')->willReturn($email); + $userBlacklist->method('getAdded')->willReturn($added); + + $blacklistManager = $this->createMock(SubscriberBlacklistManager::class); + $blacklistManager->method('getBlacklistReason')->with($email)->willReturn($reason); + + $normalizer = new UserBlacklistNormalizer($blacklistManager); + + $expected = [ + 'email' => $email, + 'added' => '2025-08-08T12:00:00+00:00', + 'reason' => $reason, + ]; + + $this->assertSame($expected, $normalizer->normalize($userBlacklist)); + } + + public function testNormalizeWithInvalidObject(): void + { + $blacklistManager = $this->createMock(SubscriberBlacklistManager::class); + $normalizer = new UserBlacklistNormalizer($blacklistManager); + + $this->assertSame([], $normalizer->normalize(new stdClass())); + } +} diff --git a/tests/Unit/Subscription/Service/SubscriberServiceTest.php b/tests/Unit/Subscription/Service/SubscriberServiceTest.php deleted file mode 100644 index eef8d1a8..00000000 --- a/tests/Unit/Subscription/Service/SubscriberServiceTest.php +++ /dev/null @@ -1,174 +0,0 @@ -subscriberManager = $this->createMock(SubscriberManager::class); - $this->subscriberNormalizer = $this->createMock(SubscriberNormalizer::class); - $this->subscriberHistoryService = $this->createMock(SubscriberHistoryService::class); - - $this->subscriberService = new SubscriberService( - $this->subscriberManager, - $this->subscriberNormalizer, - $this->subscriberHistoryService - ); - } - - public function testCreateSubscriberReturnsNormalizedSubscriber(): void - { - $subscriberDto = $this->createMock(CreateSubscriberDto::class); - $createSubscriberRequest = $this->createMock(CreateSubscriberRequest::class); - $subscriber = $this->createMock(Subscriber::class); - $expectedResult = ['id' => 1, 'email' => 'test@example.com']; - - $createSubscriberRequest->expects($this->once()) - ->method('getDto') - ->willReturn($subscriberDto); - - $this->subscriberManager->expects($this->once()) - ->method('createSubscriber') - ->with($this->identicalTo($subscriberDto)) - ->willReturn($subscriber); - - $this->subscriberNormalizer->expects($this->once()) - ->method('normalize') - ->with($this->identicalTo($subscriber), 'json') - ->willReturn($expectedResult); - - $result = $this->subscriberService->createSubscriber($createSubscriberRequest); - - $this->assertSame($expectedResult, $result); - } - - public function testUpdateSubscriberReturnsNormalizedSubscriber(): void - { - $subscriberDto = $this->createMock(UpdateSubscriberDto::class); - $updateSubscriberRequest = $this->createMock(UpdateSubscriberRequest::class); - $subscriber = $this->createMock(Subscriber::class); - $expectedResult = ['id' => 1, 'email' => 'updated@example.com']; - - $updateSubscriberRequest->expects($this->once()) - ->method('getDto') - ->willReturn($subscriberDto); - - $this->subscriberManager->expects($this->once()) - ->method('updateSubscriber') - ->with($this->identicalTo($subscriberDto)) - ->willReturn($subscriber); - - $this->subscriberNormalizer->expects($this->once()) - ->method('normalize') - ->with($this->identicalTo($subscriber), 'json') - ->willReturn($expectedResult); - - $result = $this->subscriberService->updateSubscriber($updateSubscriberRequest); - - $this->assertSame($expectedResult, $result); - } - - public function testGetSubscriberReturnsNormalizedSubscriber(): void - { - $subscriberId = 1; - $subscriber = $this->createMock(Subscriber::class); - $expectedResult = ['id' => 1, 'email' => 'test@example.com']; - - $this->subscriberManager->expects($this->once()) - ->method('getSubscriber') - ->with($subscriberId) - ->willReturn($subscriber); - - $this->subscriberNormalizer->expects($this->once()) - ->method('normalize') - ->with($this->identicalTo($subscriber)) - ->willReturn($expectedResult); - - $result = $this->subscriberService->getSubscriber($subscriberId); - - $this->assertSame($expectedResult, $result); - } - - public function testGetSubscriberHistoryDelegatesToHistoryService(): void - { - $request = new Request(); - $subscriber = $this->createMock(Subscriber::class); - $expectedResult = ['items' => [], 'pagination' => []]; - - $this->subscriberHistoryService->expects($this->once()) - ->method('getSubscriberHistory') - ->with($this->identicalTo($request), $this->identicalTo($subscriber)) - ->willReturn($expectedResult); - - $result = $this->subscriberService->getSubscriberHistory($request, $subscriber); - - $this->assertSame($expectedResult, $result); - } - - public function testDeleteSubscriberCallsManagerDelete(): void - { - $subscriber = $this->createMock(Subscriber::class); - - $this->subscriberManager->expects($this->once()) - ->method('deleteSubscriber') - ->with($this->identicalTo($subscriber)); - - $this->subscriberService->deleteSubscriber($subscriber); - } - - public function testConfirmSubscriberWithEmptyUniqueIdReturnsNull(): void - { - $this->assertNull($this->subscriberService->confirmSubscriber('')); - } - - public function testConfirmSubscriberWithValidUniqueIdReturnsSubscriber(): void - { - $uniqueId = 'valid-unique-id'; - $subscriber = $this->createMock(Subscriber::class); - - $this->subscriberManager->expects($this->once()) - ->method('markAsConfirmedByUniqueId') - ->with($uniqueId) - ->willReturn($subscriber); - - $result = $this->subscriberService->confirmSubscriber($uniqueId); - - $this->assertSame($subscriber, $result); - } - - public function testConfirmSubscriberWithInvalidUniqueIdReturnsNull(): void - { - $uniqueId = 'invalid-unique-id'; - - $this->subscriberManager->expects($this->once()) - ->method('markAsConfirmedByUniqueId') - ->with($uniqueId) - ->willThrowException(new NotFoundHttpException()); - - $result = $this->subscriberService->confirmSubscriber($uniqueId); - - $this->assertNull($result); - } -}