diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..b354963 --- /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 b4c9b3f..aad3619 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 5b8e2a7..c4c880f 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 37f0b02..9925399 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 0000000..9073d52 --- /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 1d16cc3..2179f6a 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 772a56d..ff4c853 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 ca89723..573608b 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 77e9288..365cf12 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 de5d3d6..a3527b0 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 5f58811..66b49ce 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/Messaging/Controller/BounceRegexController.php b/src/Messaging/Controller/BounceRegexController.php new file mode 100644 index 0000000..c9e9a1c --- /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 6ebac37..b1b8e80 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 0ee3eb9..f67c8fa 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 6513db2..bc24a02 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 a14e9b5..9e4cfb5 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 0000000..90cb0e8 --- /dev/null +++ b/src/Messaging/Request/CreateBounceRegexRequest.php @@ -0,0 +1,42 @@ + $this->regex, + 'action' => $this->action, + 'listOrder' => $this->listOrder, + 'admin' => $this->admin, + 'comment' => $this->comment, + 'status' => $this->status, + ]; + } +} diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php index ca908e6..03fd332 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 0000000..5771bd8 --- /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 b659b3d..dcad635 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 b6680d1..4bc9e3c 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 0000000..9b4e958 --- /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 0000000..ef7a59c --- /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 6d7bad3..e096552 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 834b097..24b5420 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 fc7be66..e144e20 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 d084116..44fc335 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 a0ea079..304c343 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 b8bf72d..a7fffba 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 8376495..ac7eedf 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 0000000..68dc81e --- /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 0000000..f8ff01c --- /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 4a8fa97..0000000 --- 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 41f3298..71a811c 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 aa8790f..0bf1b31 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 3a47138..b0d2eb6 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 0000000..4c7872e --- /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 a47fe02..fcbf46f 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 c2e52a7..6e83106 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 3e6b4a9..42b33b5 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 0000000..fdba52a --- /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 0000000..fa2d541 --- /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 3ffb4dd..93cf93d 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 0000000..6279b9c --- /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 0000000..c0cf349 --- /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 c1c4d11..3875e04 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 0000000..8767477 --- /dev/null +++ b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php @@ -0,0 +1,46 @@ +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']); + } +} diff --git a/tests/Unit/Messaging/Serializer/BounceRegexNormalizerTest.php b/tests/Unit/Messaging/Serializer/BounceRegexNormalizerTest.php new file mode 100644 index 0000000..a86b065 --- /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 df5a33d..d90ef90 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 5293f0f..e328fe9 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 0000000..523e590 --- /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 0000000..85619b6 --- /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 eef8d1a..0000000 --- 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); - } -}