From ad52a60a3a934cb754a07ae8773c46301a73d0f0 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 8 Aug 2025 22:30:01 +0400 Subject: [PATCH 01/18] Blacklist (#156) * blacklist controller * blacklist request * UserBlacklistNormalizer * add in development flags * phpcs fix * use blacklist branch * fix swagger * use core dev branch --------- Co-authored-by: Tatevik --- composer.json | 2 +- config/services/normalizers.yml | 4 + .../Controller/BlacklistController.php | 284 ++++++++++++++++++ .../Request/AddToBlacklistRequest.php | 23 ++ .../Serializer/UserBlacklistNormalizer.php | 42 +++ .../Controller/BlacklistControllerTest.php | 60 ++++ .../UserBlacklistNormalizerTest.php | 57 ++++ 7 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 src/Subscription/Controller/BlacklistController.php create mode 100644 src/Subscription/Request/AddToBlacklistRequest.php create mode 100644 src/Subscription/Serializer/UserBlacklistNormalizer.php create mode 100644 tests/Integration/Subscription/Controller/BlacklistControllerTest.php create mode 100644 tests/Unit/Subscription/Serializer/UserBlacklistNormalizerTest.php diff --git a/composer.json b/composer.json index 5b8e2a73..ba5526cd 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 1d16cc3f..a270a3f2 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -93,3 +93,7 @@ services: PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Subscription\Serializer\UserBlacklistNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Subscription/Controller/BlacklistController.php b/src/Subscription/Controller/BlacklistController.php new file mode 100644 index 00000000..f76b094e --- /dev/null +++ b/src/Subscription/Controller/BlacklistController.php @@ -0,0 +1,284 @@ +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 + ); + $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); + + 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/Request/AddToBlacklistRequest.php b/src/Subscription/Request/AddToBlacklistRequest.php new file mode 100644 index 00000000..68dc81e7 --- /dev/null +++ b/src/Subscription/Request/AddToBlacklistRequest.php @@ -0,0 +1,23 @@ +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/tests/Integration/Subscription/Controller/BlacklistControllerTest.php b/tests/Integration/Subscription/Controller/BlacklistControllerTest.php new file mode 100644 index 00000000..fdba52ae --- /dev/null +++ b/tests/Integration/Subscription/Controller/BlacklistControllerTest.php @@ -0,0 +1,60 @@ +get(BlacklistController::class) + ); + } + + public function testCheckEmailBlacklistedWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('get', '/api/v2/blacklist/check/test@example.com'); + + $this->assertHttpForbidden(); + } + + public function testAddEmailToBlacklistWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('post', '/api/v2/blacklist/add'); + + $this->assertHttpForbidden(); + } + + public function testAddEmailToBlacklistWithMissingEmailReturnsUnprocessableEntityStatus(): void + { + $jsonData = []; + + $this->authenticatedJsonRequest('post', '/api/v2/blacklist/add', [], [], [], json_encode($jsonData)); + + $this->assertHttpUnprocessableEntity(); + } + + public function testRemoveEmailFromBlacklistWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('delete', '/api/v2/blacklist/remove/test@example.com'); + + $this->assertHttpForbidden(); + } + + public function testGetBlacklistInfoWithoutSessionKeyReturnsForbiddenStatus(): void + { + $this->jsonRequest('get', '/api/v2/blacklist/info/test@example.com'); + + $this->assertHttpForbidden(); + } +} diff --git a/tests/Unit/Subscription/Serializer/UserBlacklistNormalizerTest.php b/tests/Unit/Subscription/Serializer/UserBlacklistNormalizerTest.php new file mode 100644 index 00000000..85619b68 --- /dev/null +++ b/tests/Unit/Subscription/Serializer/UserBlacklistNormalizerTest.php @@ -0,0 +1,57 @@ +createMock(SubscriberBlacklistManager::class); + $normalizer = new UserBlacklistNormalizer($blacklistManager); + $userBlacklist = $this->createMock(UserBlacklist::class); + + $this->assertTrue($normalizer->supportsNormalization($userBlacklist)); + $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testNormalize(): void + { + $email = 'test@example.com'; + $added = new DateTime('2025-08-08T12:00:00+00:00'); + $reason = 'Unsubscribed by user'; + + $userBlacklist = $this->createMock(UserBlacklist::class); + $userBlacklist->method('getEmail')->willReturn($email); + $userBlacklist->method('getAdded')->willReturn($added); + + $blacklistManager = $this->createMock(SubscriberBlacklistManager::class); + $blacklistManager->method('getBlacklistReason')->with($email)->willReturn($reason); + + $normalizer = new UserBlacklistNormalizer($blacklistManager); + + $expected = [ + 'email' => $email, + 'added' => '2025-08-08T12:00:00+00:00', + 'reason' => $reason, + ]; + + $this->assertSame($expected, $normalizer->normalize($userBlacklist)); + } + + public function testNormalizeWithInvalidObject(): void + { + $blacklistManager = $this->createMock(SubscriberBlacklistManager::class); + $normalizer = new UserBlacklistNormalizer($blacklistManager); + + $this->assertSame([], $normalizer->normalize(new stdClass())); + } +} From 3b839be49d5ce59845849cb85b6594c5ecb3f5cd Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 18 Aug 2025 09:10:28 +0400 Subject: [PATCH 02/18] Subscribepage (#157) * SubscribePageController * SubscribePageNormalizer * SubscribePageCreateRequest * Get/set page data, update/delete page, add tests --------- Co-authored-by: Tatevik --- composer.json | 2 +- config/services/normalizers.yml | 4 + .../Controller/SubscribePageController.php | 433 ++++++++++++++++++ .../OpenApi/SwaggerSchemasResponse.php | 9 + .../Request/SubscribePageDataRequest.php | 21 + .../Request/SubscribePageRequest.php | 23 + .../Serializer/SubscribePageNormalizer.php | 42 ++ .../SubscribePageControllerTest.php | 291 ++++++++++++ .../Subscription/Fixtures/SubscribePage.csv | 3 + .../Fixtures/SubscribePageFixture.php | 62 +++ .../SubscribePageNormalizerTest.php | 71 +++ 11 files changed, 960 insertions(+), 1 deletion(-) create mode 100644 src/Subscription/Controller/SubscribePageController.php create mode 100644 src/Subscription/Request/SubscribePageDataRequest.php create mode 100644 src/Subscription/Request/SubscribePageRequest.php create mode 100644 src/Subscription/Serializer/SubscribePageNormalizer.php create mode 100644 tests/Integration/Subscription/Controller/SubscribePageControllerTest.php create mode 100644 tests/Integration/Subscription/Fixtures/SubscribePage.csv create mode 100644 tests/Integration/Subscription/Fixtures/SubscribePageFixture.php create mode 100644 tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php diff --git a/composer.json b/composer.json index ba5526cd..89dbf442 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-dev", + "phplist/core": "dev-subscribepage", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index a270a3f2..66ee7def 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -97,3 +97,7 @@ services: PhpList\RestBundle\Subscription\Serializer\UserBlacklistNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php new file mode 100644 index 00000000..8f3878c4 --- /dev/null +++ b/src/Subscription/Controller/SubscribePageController.php @@ -0,0 +1,433 @@ + '\\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); + + 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, + ); + + 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); + + 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); + + return $this->json([ + 'id' => $item->getId(), + 'name' => $item->getName(), + 'data' => $item->getData(), + ], Response::HTTP_OK); + } +} diff --git a/src/Subscription/OpenApi/SwaggerSchemasResponse.php b/src/Subscription/OpenApi/SwaggerSchemasResponse.php index 83764956..ac7eedfb 100644 --- a/src/Subscription/OpenApi/SwaggerSchemasResponse.php +++ b/src/Subscription/OpenApi/SwaggerSchemasResponse.php @@ -142,6 +142,15 @@ ), ], )] +#[OA\Schema( + schema: 'SubscribePage', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'title', type: 'string', example: 'Subscribe to our newsletter'), + new OA\Property(property: 'active', type: 'boolean', example: true), + new OA\Property(property: 'owner', ref: '#/components/schemas/Administrator'), + ], +)] class SwaggerSchemasResponse { } diff --git a/src/Subscription/Request/SubscribePageDataRequest.php b/src/Subscription/Request/SubscribePageDataRequest.php new file mode 100644 index 00000000..9af2ec08 --- /dev/null +++ b/src/Subscription/Request/SubscribePageDataRequest.php @@ -0,0 +1,21 @@ + $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/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php new file mode 100644 index 00000000..d9da7b15 --- /dev/null +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -0,0 +1,291 @@ +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 + { + $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/Fixtures/SubscribePage.csv b/tests/Integration/Subscription/Fixtures/SubscribePage.csv new file mode 100644 index 00000000..6279b9c4 --- /dev/null +++ b/tests/Integration/Subscription/Fixtures/SubscribePage.csv @@ -0,0 +1,3 @@ +id,title,active,owner +1,"Welcome Page",1,1 +2,"Inactive Page",0,1 diff --git a/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php b/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php new file mode 100644 index 00000000..a22b2465 --- /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((bool)$row['active']); + $page->setOwner($owner); + + $manager->persist($page); + } while (true); + + fclose($handle); + } +} diff --git a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php new file mode 100644 index 00000000..523e5904 --- /dev/null +++ b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php @@ -0,0 +1,71 @@ +createMock(AdministratorNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer); + + $page = $this->createMock(SubscribePage::class); + + $this->assertTrue($normalizer->supportsNormalization($page)); + $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $owner = $this->createMock(Administrator::class); + + $page = $this->createMock(SubscribePage::class); + $page->method('getId')->willReturn(42); + $page->method('getTitle')->willReturn('welcome@example.org'); + $page->method('isActive')->willReturn(true); + $page->method('getOwner')->willReturn($owner); + + $adminData = [ + 'id' => 7, + 'login_name' => 'admin', + 'email' => 'admin@example.org', + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], + ]; + + $adminNormalizer = $this->createMock(AdministratorNormalizer::class); + $adminNormalizer->method('normalize')->with($owner)->willReturn($adminData); + + $normalizer = new SubscribePageNormalizer($adminNormalizer); + + $expected = [ + 'id' => 42, + 'title' => 'welcome@example.org', + 'active' => true, + 'owner' => $adminData, + ]; + + $this->assertSame($expected, $normalizer->normalize($page)); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $adminNormalizer = $this->createMock(AdministratorNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer); + + $this->assertSame([], $normalizer->normalize(new stdClass())); + } +} From 5b7835dc541a581fe5d1f09ae713240fecd6b2dc Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Tue, 19 Aug 2025 10:12:00 +0400 Subject: [PATCH 03/18] Bounceregex (#158) * Add BounceRegexController * Use code dev branch --------- Co-authored-by: Tatevik --- composer.json | 2 +- config/services/managers.yml | 10 +- config/services/normalizers.yml | 4 + .../Controller/BounceRegexController.php | 246 ++++++++++++++++++ .../Controller/TemplateController.php | 2 +- .../OpenApi/SwaggerSchemasResponse.php | 15 ++ .../Request/CreateBounceRegexRequest.php | 42 +++ .../Serializer/BounceRegexNormalizer.php | 41 +++ src/Messaging/Service/CampaignService.php | 2 +- .../Controller/BounceRegexControllerTest.php | 81 ++++++ .../Request/CreateBounceRegexRequestTest.php | 46 ++++ .../Serializer/BounceRegexNormalizerTest.php | 60 +++++ .../Messaging/Service/CampaignServiceTest.php | 2 +- 13 files changed, 546 insertions(+), 7 deletions(-) create mode 100644 src/Messaging/Controller/BounceRegexController.php create mode 100644 src/Messaging/Request/CreateBounceRegexRequest.php create mode 100644 src/Messaging/Serializer/BounceRegexNormalizer.php create mode 100644 tests/Integration/Messaging/Controller/BounceRegexControllerTest.php create mode 100644 tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php create mode 100644 tests/Unit/Messaging/Serializer/BounceRegexNormalizerTest.php diff --git a/composer.json b/composer.json index 89dbf442..ba5526cd 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-subscribepage", + "phplist/core": "dev-dev", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", diff --git a/config/services/managers.yml b/config/services/managers.yml index 37f0b029..99253992 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -20,15 +20,19 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\MessageManager: + PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateImageManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: autowire: true autoconfigure: true diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 66ee7def..2179f6ad 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -101,3 +101,7 @@ services: PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Messaging\Serializer\BounceRegexNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Messaging/Controller/BounceRegexController.php b/src/Messaging/Controller/BounceRegexController.php new file mode 100644 index 00000000..7f3f275b --- /dev/null +++ b/src/Messaging/Controller/BounceRegexController.php @@ -0,0 +1,246 @@ +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 + ); + + 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); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Messaging/Controller/TemplateController.php b/src/Messaging/Controller/TemplateController.php index 6513db26..b814c89e 100644 --- a/src/Messaging/Controller/TemplateController.php +++ b/src/Messaging/Controller/TemplateController.php @@ -6,7 +6,7 @@ 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; diff --git a/src/Messaging/OpenApi/SwaggerSchemasResponse.php b/src/Messaging/OpenApi/SwaggerSchemasResponse.php index a14e9b58..9e4cfb55 100644 --- a/src/Messaging/OpenApi/SwaggerSchemasResponse.php +++ b/src/Messaging/OpenApi/SwaggerSchemasResponse.php @@ -120,6 +120,21 @@ ], type: 'object' )] +#[OA\Schema( + schema: 'BounceRegex', + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 10), + new OA\Property(property: 'regex', type: 'string', example: '/mailbox is full/i'), + new OA\Property(property: 'regex_hash', type: 'string', example: 'd41d8cd98f00b204e9800998ecf8427e'), + new OA\Property(property: 'action', type: 'string', example: 'delete', nullable: true), + new OA\Property(property: 'list_order', type: 'integer', example: 0, nullable: true), + new OA\Property(property: 'admin_id', type: 'integer', example: 1, nullable: true), + new OA\Property(property: 'comment', type: 'string', example: 'Auto-generated rule', nullable: true), + new OA\Property(property: 'status', type: 'string', example: 'active', nullable: true), + new OA\Property(property: 'count', type: 'integer', example: 5, nullable: true), + ], + type: 'object' +)] class SwaggerSchemasResponse { } diff --git a/src/Messaging/Request/CreateBounceRegexRequest.php b/src/Messaging/Request/CreateBounceRegexRequest.php new file mode 100644 index 00000000..69771191 --- /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/Serializer/BounceRegexNormalizer.php b/src/Messaging/Serializer/BounceRegexNormalizer.php new file mode 100644 index 00000000..5771bd8b --- /dev/null +++ b/src/Messaging/Serializer/BounceRegexNormalizer.php @@ -0,0 +1,41 @@ + $object->getId(), + 'regex' => $object->getRegex(), + 'regex_hash' => $object->getRegexHash(), + 'action' => $object->getAction(), + 'list_order' => $object->getListOrder(), + 'admin_id' => $object->getAdminId(), + 'comment' => $object->getComment(), + 'status' => $object->getStatus(), + 'count' => $object->getCount(), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof BounceRegex; + } +} diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index b6680d1e..d2cd8c0a 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -8,7 +8,7 @@ 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; diff --git a/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php b/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php new file mode 100644 index 00000000..4c7872e4 --- /dev/null +++ b/tests/Integration/Messaging/Controller/BounceRegexControllerTest.php @@ -0,0 +1,81 @@ +get(BounceRegexController::class)); + } + + public function testListWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/bounces/regex'); + $this->assertHttpForbidden(); + } + + public function testListWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/bounces/regex', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testListWithValidSessionKeyReturnsOkay(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex'); + $this->assertHttpOkay(); + } + + public function testCreateGetDeleteFlow(): void + { + $payload = json_encode([ + 'regex' => '/mailbox is full/i', + 'action' => 'delete', + 'list_order' => 0, + 'admin' => 1, + 'comment' => 'Auto-generated rule', + 'status' => 'active', + ]); + + $this->authenticatedJsonRequest('POST', '/api/v2/bounces/regex', [], [], [], $payload); + $this->assertHttpCreated(); + $created = $this->getDecodedJsonResponseContent(); + $this->assertSame('/mailbox is full/i', $created['regex']); + $this->assertSame(md5('/mailbox is full/i'), $created['regex_hash']); + + $hash = $created['regex_hash']; + $this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex/' . $hash); + $this->assertHttpOkay(); + $one = $this->getDecodedJsonResponseContent(); + $this->assertSame($hash, $one['regex_hash']); + + $this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex'); + $this->assertHttpOkay(); + $list = $this->getDecodedJsonResponseContent(); + $this->assertIsArray($list); + $this->assertIsArray($list[0] ?? []); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/bounces/regex/' . $hash); + $this->assertHttpNoContent(); + + $this->authenticatedJsonRequest('GET', '/api/v2/bounces/regex/' . $hash); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php new file mode 100644 index 00000000..8767477f --- /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 00000000..a86b0657 --- /dev/null +++ b/tests/Unit/Messaging/Serializer/BounceRegexNormalizerTest.php @@ -0,0 +1,60 @@ +normalizer = new BounceRegexNormalizer(); + } + + public function testSupportsNormalization(): void + { + $regex = new BounceRegex(); + $this->assertTrue($this->normalizer->supportsNormalization($regex)); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $regexPattern = '/mailbox is full/i'; + $hash = md5($regexPattern); + + $entity = new BounceRegex( + regex: $regexPattern, + regexHash: $hash, + action: 'delete', + listOrder: 2, + adminId: 42, + comment: 'Auto-generated rule', + status: 'active', + count: 7 + ); + + $result = $this->normalizer->normalize($entity); + + $this->assertSame($regexPattern, $result['regex']); + $this->assertSame($hash, $result['regex_hash']); + $this->assertSame('delete', $result['action']); + $this->assertSame(2, $result['list_order']); + $this->assertSame(42, $result['admin_id']); + $this->assertSame('Auto-generated rule', $result['comment']); + $this->assertSame('active', $result['status']); + $this->assertSame(7, $result['count']); + $this->assertArrayHasKey('id', $result); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $this->assertSame([], $this->normalizer->normalize(new \stdClass())); + } +} diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php index 5293f0f9..0a3d2e4a 100644 --- a/tests/Unit/Messaging/Service/CampaignServiceTest.php +++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php @@ -11,7 +11,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; From 3ed98fbdb38e157551d84054dda4591d7cb37ae7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 23 Sep 2025 11:44:22 +0400 Subject: [PATCH 04/18] Fix message status (enum) --- .../Serializer/MessageNormalizer.php | 2 +- .../Messaging/Fixtures/MessageFixture.php | 52 +++++++++---------- .../Serializer/MessageNormalizerTest.php | 4 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php index b659b3d6..dcad6358 100644 --- a/src/Messaging/Serializer/MessageNormalizer.php +++ b/src/Messaging/Serializer/MessageNormalizer.php @@ -39,7 +39,7 @@ public function normalize($object, string $format = null, array $context = []): 'format_options' => $object->getFormat()->getFormatOptions() ], 'message_metadata' => [ - 'status' => $object->getMetadata()->getStatus(), + 'status' => $object->getMetadata()->getStatus()->value, 'processed' => $object->getMetadata()->isProcessed(), 'views' => $object->getMetadata()->getViews(), 'bounce_count' => $object->getMetadata()->getBounceCount(), diff --git a/tests/Integration/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Messaging/Fixtures/MessageFixture.php index 3e6b4a99..0f5891b1 100644 --- a/tests/Integration/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Messaging/Fixtures/MessageFixture.php @@ -59,43 +59,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/Unit/Messaging/Serializer/MessageNormalizerTest.php b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php index df5a33d2..36f41d2f 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']); } From 0f452033aea975d8fb02785730994ff459164160 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 23 Sep 2025 11:44:46 +0400 Subject: [PATCH 05/18] Fix function name --- src/Subscription/Service/SubscriberService.php | 2 +- tests/Unit/Subscription/Service/SubscriberServiceTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Subscription/Service/SubscriberService.php b/src/Subscription/Service/SubscriberService.php index 4a8fa97a..893b8923 100644 --- a/src/Subscription/Service/SubscriberService.php +++ b/src/Subscription/Service/SubscriberService.php @@ -35,7 +35,7 @@ public function updateSubscriber(UpdateSubscriberRequest $updateSubscriberReques public function getSubscriber(int $subscriberId): array { - $subscriber = $this->subscriberManager->getSubscriber($subscriberId); + $subscriber = $this->subscriberManager->getSubscriberById($subscriberId); return $this->subscriberNormalizer->normalize($subscriber); } diff --git a/tests/Unit/Subscription/Service/SubscriberServiceTest.php b/tests/Unit/Subscription/Service/SubscriberServiceTest.php index eef8d1a8..ffc27dc4 100644 --- a/tests/Unit/Subscription/Service/SubscriberServiceTest.php +++ b/tests/Unit/Subscription/Service/SubscriberServiceTest.php @@ -97,7 +97,7 @@ public function testGetSubscriberReturnsNormalizedSubscriber(): void $expectedResult = ['id' => 1, 'email' => 'test@example.com']; $this->subscriberManager->expects($this->once()) - ->method('getSubscriber') + ->method('getSubscriberById') ->with($subscriberId) ->willReturn($subscriber); From abcdb9cd57396c44bc7c8adc067a6a907c54b627 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 23 Sep 2025 12:18:32 +0400 Subject: [PATCH 06/18] Fix send campaign (status transition) --- .../Controller/CampaignController.php | 2 +- .../Controller/CampaignControllerTest.php | 4 ++-- .../Messaging/Fixtures/Message.csv | 20 +++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index 6ebac37a..d897706c 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -6,7 +6,7 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Service\CampaignProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; diff --git a/tests/Integration/Messaging/Controller/CampaignControllerTest.php b/tests/Integration/Messaging/Controller/CampaignControllerTest.php index a47fe02b..fcbf46f0 100644 --- a/tests/Integration/Messaging/Controller/CampaignControllerTest.php +++ b/tests/Integration/Messaging/Controller/CampaignControllerTest.php @@ -98,11 +98,11 @@ public function testSendMessageWithValidSessionReturnsOkay(): void { $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); - $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/1/send'); + $this->authenticatedJsonRequest('POST', '/api/v2/campaigns/2/send'); $this->assertHttpOkay(); $response = $this->getDecodedJsonResponseContent(); - self::assertSame(1, $response['id']); + self::assertSame(2, $response['id']); } public function testSendMessageWithInvalidIdReturnsNotFound(): void diff --git a/tests/Integration/Messaging/Fixtures/Message.csv b/tests/Integration/Messaging/Fixtures/Message.csv index c2e52a79..6e831060 100644 --- a/tests/Integration/Messaging/Fixtures/Message.csv +++ b/tests/Integration/Messaging/Fixtures/Message.csv @@ -19,3 +19,23 @@ id,uuid,subject,fromfield,tofield,replyto,message,textmessage,footer,entered,mod ",2024-11-10 16:57:46,2024-11-14 08:32:15,2024-11-14 08:32:00,0,2024-11-14 08:32:00,0,2024-11-14 08:32:00,sent,,2024-11-14 08:32:15,1,invite,0,0,0,0,0,0,0,0,0,2024-11-14 08:32:15,,1 +2,2df6b147-8470-45ed-8e4e-86aa01af400f,Do you want to continue receiving our messages?, My Name ,"","","

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

+ +

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

+ +

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

+ +

Update preferences

+ +

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

+ +

Thank you!

","","-- + +
+

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

+

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

+
+ + ",2024-11-10 16:57:46,2024-11-14 08:32:15,2024-11-14 08:32:00,0,2024-11-14 08:32:00,0,2024-11-14 08:32:00,submitted,,2024-11-14 08:32:15,1,invite,0,0,0,0,0,0,0,0,0,2024-11-14 08:32:15,,1 From ffa7078f98d22f969d7b2b074e70e68103b075a8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 23 Sep 2025 12:28:27 +0400 Subject: [PATCH 07/18] Fix MessageMetadataDto --- src/Messaging/Request/Message/MessageMetadataRequest.php | 6 +++++- tests/Unit/Messaging/Serializer/MessageNormalizerTest.php | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php index ca908e62..03fd3323 100644 --- a/src/Messaging/Request/Message/MessageMetadataRequest.php +++ b/src/Messaging/Request/Message/MessageMetadataRequest.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Messaging\Request\Message; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageMetadataDto; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use Symfony\Component\Validator\Constraints as Assert; class MessageMetadataRequest implements RequestDtoInterface @@ -12,10 +13,13 @@ class MessageMetadataRequest implements RequestDtoInterface #[Assert\NotBlank] public string $status; + /** + * @SuppressWarnings(PHPMD.StaticAccess) + */ public function getDto(): MessageMetadataDto { return new MessageMetadataDto( - status: $this->status, + status: MessageStatus::from($this->status), ); } } diff --git a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php index 36f41d2f..d90ef900 100644 --- a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php +++ b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php @@ -46,7 +46,7 @@ public function testNormalizeReturnsExpectedArray(): void $entered = new DateTime('2025-01-01T10:00:00+00:00'); $sent = new DateTime('2025-01-02T10:00:00+00:00'); - $metadata = new Message\MessageMetadata( Message\MessageStatus::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( Message\MessageStatus::Draft->value, $result['message_metadata']['status']); + $this->assertSame(Message\MessageStatus::Draft->value, $result['message_metadata']['status']); $this->assertSame('from@example.com', $result['message_options']['from_field']); } From 274849110e7a17328fdcf4340429d9dd288b04f1 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 24 Sep 2025 12:17:44 +0400 Subject: [PATCH 08/18] Add: Reset subscriber bounce count --- .../Controller/SubscriberController.php | 68 +++++++++++++++++++ .../Service/SubscriberService.php | 11 +++ 2 files changed, 79 insertions(+) diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index fc7be66b..f053dff8 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -375,6 +375,74 @@ public function deleteSubscriber( 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.'); + } + + $subscriberData = $this->subscriberService->resetSubscriberBounceCount($subscriber); + + return $this->json($subscriberData, Response::HTTP_OK); + } + #[Route('/confirm', name: 'confirm', methods: ['GET'])] #[OA\Get( path: '/api/v2/subscribers/confirm', diff --git a/src/Subscription/Service/SubscriberService.php b/src/Subscription/Service/SubscriberService.php index 893b8923..2ddce62f 100644 --- a/src/Subscription/Service/SubscriberService.php +++ b/src/Subscription/Service/SubscriberService.php @@ -12,6 +12,11 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +/** + * This encapsulates subscriber-related services + * + * @author Tatevik Grigoryan + */ class SubscriberService { public function __construct( @@ -33,6 +38,12 @@ public function updateSubscriber(UpdateSubscriberRequest $updateSubscriberReques return $this->subscriberNormalizer->normalize($subscriber, 'json'); } + public function resetSubscriberBounceCount(Subscriber $subscriber): array + { + $subscriber = $this->subscriberManager->resetBounceCount($subscriber); + return $this->subscriberNormalizer->normalize($subscriber, 'json'); + } + public function getSubscriber(int $subscriberId): array { $subscriber = $this->subscriberManager->getSubscriberById($subscriberId); From c68921c0752a2d00f87e3eade33e61fc18d4eeb6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 24 Sep 2025 12:29:34 +0400 Subject: [PATCH 09/18] Catch CouldNotReadUploadedFileException --- src/Subscription/Controller/SubscriberImportController.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Subscription/Controller/SubscriberImportController.php b/src/Subscription/Controller/SubscriberImportController.php index d084116a..44fc3353 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() From 95b0e62d9bdecb553957e1c6f0d750a25ef97f37 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 12 Oct 2025 19:56:14 +0400 Subject: [PATCH 10/18] Flush in controller --- src/Identity/Controller/AdminAttributeValueController.php | 7 ++++++- src/Subscription/Controller/SubscriptionController.php | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Identity/Controller/AdminAttributeValueController.php b/src/Identity/Controller/AdminAttributeValueController.php index ca897234..964bd83f 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); diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index b8bf72d0..a7fffba3 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; @@ -28,16 +29,19 @@ class SubscriptionController extends BaseController { private SubscriptionManager $subscriptionManager; private SubscriptionNormalizer $subscriptionNormalizer; + private EntityManagerInterface $entityManager; public function __construct( Authentication $authentication, RequestValidator $validator, SubscriptionManager $subscriptionManager, SubscriptionNormalizer $subscriptionNormalizer, + EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->subscriptionManager = $subscriptionManager; $this->subscriptionNormalizer = $subscriptionNormalizer; + $this->entityManager = $entityManager; } #[Route('/{listId}/subscribers', name: 'create', requirements: ['listId' => '\d+'], methods: ['POST'])] @@ -127,6 +131,7 @@ public function createSubscription( /** @var SubscriptionRequest $subscriptionRequest */ $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); + $this->entityManager->flush(); $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); return $this->json($normalized, Response::HTTP_CREATED); @@ -193,6 +198,7 @@ public function deleteSubscriptions( /** @var SubscriptionRequest $subscriptionRequest */ $subscriptionRequest = $this->validator->validateDto($subscriptionRequest); $this->subscriptionManager->deleteSubscriptions($list, $subscriptionRequest->emails); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } From d68ce67804ff962eb4c86445ed1fa879a1ca2956 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 13 Oct 2025 11:06:45 +0400 Subject: [PATCH 11/18] Fix composer json --- composer.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ba5526cd..99e5fcad 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 + } } } From 053422d5ce026d00b1cec0bee03a294ba56b5f17 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Wed, 22 Oct 2025 13:54:44 +0400 Subject: [PATCH 12/18] Em flush (#160) * Fix composer json * Add flush after delete * Fix: test * SubscriberBlacklistManager * listMessageManager * AdminController flush * TemplateController flush * SubscribePageController flush * save * fix test --------- Co-authored-by: Tatevik --- config/services/messenger_handlers.yml | 5 + .../AdminAttributeDefinitionController.php | 8 +- .../AdminAttributeValueController.php | 2 + .../Controller/AdministratorController.php | 7 +- .../Controller/PasswordResetController.php | 5 + src/Identity/Controller/SessionController.php | 4 + .../Controller/BounceRegexController.php | 4 + .../Controller/CampaignController.php | 28 +-- .../Controller/ListMessageController.php | 5 +- .../Controller/TemplateController.php | 7 +- src/Messaging/Service/CampaignService.php | 3 + .../Controller/BlacklistController.php | 4 + .../Controller/SubscribePageController.php | 4 + ...ubscriberAttributeDefinitionController.php | 7 +- .../SubscriberAttributeValueController.php | 6 +- .../Controller/SubscriberController.php | 41 +++-- .../Controller/SubscriberListController.php | 6 + .../Service/SubscriberService.php | 75 -------- .../Controller/SubscriberControllerTest.php | 2 + .../Messaging/Service/CampaignServiceTest.php | 8 +- .../Service/SubscriberServiceTest.php | 174 ------------------ 21 files changed, 120 insertions(+), 285 deletions(-) create mode 100644 config/services/messenger_handlers.yml delete mode 100644 src/Subscription/Service/SubscriberService.php delete mode 100644 tests/Unit/Subscription/Service/SubscriberServiceTest.php diff --git a/config/services/messenger_handlers.yml b/config/services/messenger_handlers.yml new file mode 100644 index 00000000..9073d52f --- /dev/null +++ b/config/services/messenger_handlers.yml @@ -0,0 +1,5 @@ +services: + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: + autowire: true + autoconfigure: true + public: false diff --git a/src/Identity/Controller/AdminAttributeDefinitionController.php b/src/Identity/Controller/AdminAttributeDefinitionController.php index 772a56d1..ff4c8531 100644 --- a/src/Identity/Controller/AdminAttributeDefinitionController.php +++ b/src/Identity/Controller/AdminAttributeDefinitionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; @@ -32,7 +33,8 @@ public function __construct( RequestValidator $validator, AdminAttributeDefinitionManager $definitionManager, AdminAttributeDefinitionNormalizer $normalizer, - PaginatedDataProvider $paginatedDataProvider + PaginatedDataProvider $paginatedDataProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->definitionManager = $definitionManager; @@ -89,6 +91,8 @@ public function create(Request $request): JsonResponse $definitionRequest = $this->validator->validate($request, CreateAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->create($definitionRequest->getDto()); + $this->entityManager->flush(); + $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -156,6 +160,7 @@ public function update( attributeDefinition: $attributeDefinition, attributeDefinitionDto: $definitionRequest->getDto(), ); + $this->entityManager->flush(); $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_OK); @@ -211,6 +216,7 @@ public function delete( } $this->definitionManager->delete($attributeDefinition); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Identity/Controller/AdminAttributeValueController.php b/src/Identity/Controller/AdminAttributeValueController.php index 964bd83f..573608ba 100644 --- a/src/Identity/Controller/AdminAttributeValueController.php +++ b/src/Identity/Controller/AdminAttributeValueController.php @@ -198,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); } @@ -355,6 +356,7 @@ public function getAttributeDefinition( attributeDefinitionId: $definition->getId() ); $this->attributeManager->delete($attribute); + $this->entityManager->flush(); return $this->json( $this->normalizer->normalize($attribute), diff --git a/src/Identity/Controller/AdministratorController.php b/src/Identity/Controller/AdministratorController.php index 77e9288b..365cf125 100644 --- a/src/Identity/Controller/AdministratorController.php +++ b/src/Identity/Controller/AdministratorController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Service\AdministratorManager; @@ -35,7 +36,8 @@ public function __construct( RequestValidator $validator, AdministratorManager $administratorManager, AdministratorNormalizer $normalizer, - PaginatedDataProvider $paginatedProvider + PaginatedDataProvider $paginatedProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->administratorManager = $administratorManager; @@ -149,6 +151,7 @@ public function createAdministrator( $createRequest = $validator->validate($request, CreateAdministratorRequest::class); $administrator = $this->administratorManager->createAdministrator($createRequest->getDto()); + $this->entityManager->flush(); $json = $normalizer->normalize($administrator, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -255,6 +258,7 @@ public function updateAdministrator( /** @var UpdateAdministratorRequest $updateRequest */ $updateRequest = $this->validator->validate($request, UpdateAdministratorRequest::class); $this->administratorManager->updateAdministrator($administrator, $updateRequest->getDto()); + $this->entityManager->flush(); return $this->json($this->normalizer->normalize($administrator), Response::HTTP_OK); } @@ -303,6 +307,7 @@ public function deleteAdministrator( throw $this->createNotFoundException('Administrator not found.'); } $this->administratorManager->deleteAdministrator($administrator); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Identity/Controller/PasswordResetController.php b/src/Identity/Controller/PasswordResetController.php index de5d3d67..a3527b07 100644 --- a/src/Identity/Controller/PasswordResetController.php +++ b/src/Identity/Controller/PasswordResetController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Service\PasswordManager; use PhpList\Core\Security\Authentication; @@ -29,6 +30,7 @@ public function __construct( Authentication $authentication, RequestValidator $validator, PasswordManager $passwordManager, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); @@ -74,6 +76,7 @@ public function requestPasswordReset(Request $request): JsonResponse $resetRequest = $this->validator->validate($request, RequestPasswordResetRequest::class); $this->passwordManager->generatePasswordResetToken($resetRequest->email); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -117,6 +120,7 @@ public function validateToken(Request $request): JsonResponse $validateRequest = $this->validator->validate($request, ValidateTokenRequest::class); $administrator = $this->passwordManager->validatePasswordResetToken($validateRequest->token); + $this->entityManager->flush(); return $this->json([ 'valid' => $administrator !== null]); } @@ -169,6 +173,7 @@ public function resetPassword(Request $request): JsonResponse $resetRequest->token, $resetRequest->newPassword ); + $this->entityManager->flush(); if ($success) { return $this->json([ 'message' => 'Password updated successfully']); diff --git a/src/Identity/Controller/SessionController.php b/src/Identity/Controller/SessionController.php index 5f588119..66b49ce9 100644 --- a/src/Identity/Controller/SessionController.php +++ b/src/Identity/Controller/SessionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Identity\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Service\SessionManager; @@ -34,6 +35,7 @@ public function __construct( Authentication $authentication, RequestValidator $validator, SessionManager $sessionManager, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); @@ -96,6 +98,7 @@ public function createSession( loginName:$createSessionRequest->loginName, password: $createSessionRequest->password ); + $this->entityManager->flush(); $json = $normalizer->normalize($token, 'json'); @@ -163,6 +166,7 @@ public function deleteSession( } $this->sessionManager->deleteSession($token); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Messaging/Controller/BounceRegexController.php b/src/Messaging/Controller/BounceRegexController.php index 7f3f275b..c9e9a1cb 100644 --- a/src/Messaging/Controller/BounceRegexController.php +++ b/src/Messaging/Controller/BounceRegexController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Messaging\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager; use PhpList\Core\Security\Authentication; @@ -27,6 +28,7 @@ public function __construct( RequestValidator $validator, private readonly BounceRegexManager $manager, private readonly BounceRegexNormalizer $normalizer, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); } @@ -188,6 +190,7 @@ public function createOrUpdate(Request $request): JsonResponse comment: $dto->comment, status: $dto->status ); + $this->entityManager->flush(); return $this->json($this->normalizer->normalize($entity), Response::HTTP_CREATED); } @@ -240,6 +243,7 @@ public function delete(Request $request, string $regexHash): JsonResponse 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 d897706c..b1b8e804 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -4,9 +4,10 @@ namespace PhpList\RestBundle\Messaging\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; @@ -17,6 +18,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -28,17 +30,18 @@ class CampaignController extends BaseController { private CampaignService $campaignService; - private CampaignProcessor $campaignProcessor; + private MessageBusInterface $messageBus; public function __construct( Authentication $authentication, RequestValidator $validator, CampaignService $campaignService, - CampaignProcessor $campaignProcessor, + MessageBusInterface $messageBus, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->campaignService = $campaignService; - $this->campaignProcessor = $campaignProcessor; + $this->messageBus = $messageBus; } #[Route('', name: 'get_list', methods: ['GET'])] @@ -211,11 +214,10 @@ public function createMessage(Request $request): JsonResponse /** @var CreateMessageRequest $createMessageRequest */ $createMessageRequest = $this->validator->validate($request, CreateMessageRequest::class); + $message = $this->campaignService->createMessage($createMessageRequest, $authUser); + $this->entityManager->flush(); - return $this->json( - $this->campaignService->createMessage($createMessageRequest, $authUser), - Response::HTTP_CREATED - ); + return $this->json(data: $message, status: Response::HTTP_CREATED); } #[Route('/{messageId}', name: 'update', requirements: ['messageId' => '\d+'], methods: ['PUT'])] @@ -284,11 +286,10 @@ public function updateMessage( /** @var UpdateMessageRequest $updateMessageRequest */ $updateMessageRequest = $this->validator->validate($request, UpdateMessageRequest::class); + $message = $this->campaignService->updateMessage($updateMessageRequest, $authUser, $message); + $this->entityManager->flush(); - return $this->json( - $this->campaignService->updateMessage($updateMessageRequest, $authUser, $message), - Response::HTTP_OK - ); + return $this->json(data:$message, status: Response::HTTP_OK); } #[Route('/{messageId}', name: 'delete', requirements: ['messageId' => '\d+'], methods: ['DELETE'])] @@ -339,6 +340,7 @@ public function deleteMessage( $authUser = $this->requireAuthentication($request); $this->campaignService->deleteMessage($authUser, $message); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -388,7 +390,7 @@ public function sendMessage( throw $this->createNotFoundException('Campaign not found.'); } - $this->campaignProcessor->process($message); + $this->messageBus->dispatch(new SyncCampaignProcessorMessage($message->getId())); return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); } diff --git a/src/Messaging/Controller/ListMessageController.php b/src/Messaging/Controller/ListMessageController.php index 0ee3eb9e..f67c8fa9 100644 --- a/src/Messaging/Controller/ListMessageController.php +++ b/src/Messaging/Controller/ListMessageController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Messaging\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager; @@ -37,7 +38,8 @@ public function __construct( ListMessageManager $listMessageManager, ListMessageNormalizer $listMessageNormalizer, SubscriberListNormalizer $subscriberListNormalizer, - MessageNormalizer $messageNormalizer + MessageNormalizer $messageNormalizer, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->listMessageManager = $listMessageManager; @@ -262,6 +264,7 @@ public function associateMessageWithList( } $listMessage = $this->listMessageManager->associateMessageWithList($message, $subscriberList); + $this->entityManager->flush(); return $this->json( data: $this->listMessageNormalizer->normalize($listMessage), diff --git a/src/Messaging/Controller/TemplateController.php b/src/Messaging/Controller/TemplateController.php index b814c89e..bc24a02d 100644 --- a/src/Messaging/Controller/TemplateController.php +++ b/src/Messaging/Controller/TemplateController.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\Template; use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager; @@ -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/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index d2cd8c0a..4bc9e3c6 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -4,6 +4,7 @@ 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; @@ -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 index f76b094e..9b4e9580 100644 --- a/src/Subscription/Controller/BlacklistController.php +++ b/src/Subscription/Controller/BlacklistController.php @@ -4,6 +4,7 @@ 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\Service\Manager\SubscriberBlacklistManager; @@ -31,6 +32,7 @@ public function __construct( RequestValidator $validator, SubscriberBlacklistManager $blacklistManager, UserBlacklistNormalizer $normalizer, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->authentication = $authentication; @@ -156,6 +158,7 @@ public function addEmailToBlacklist(Request $request): JsonResponse email: $definitionRequest->email, reasonData: $definitionRequest->reason ); + $this->entityManager->flush(); $json = $this->normalizer->normalize($userBlacklisted, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -209,6 +212,7 @@ public function removeEmailFromBlacklist(Request $request, string $email): JsonR } $this->blacklistManager->removeEmailFromBlacklist($email); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 8f3878c4..38117c8d 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -4,6 +4,7 @@ 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\SubscribePage; @@ -28,6 +29,7 @@ public function __construct( RequestValidator $validator, private readonly SubscribePageManager $subscribePageManager, private readonly SubscribePageNormalizer $normalizer, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); } @@ -141,6 +143,7 @@ public function createPage(Request $request): JsonResponse $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); } @@ -423,6 +426,7 @@ public function setPageData( $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(), diff --git a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php index 6d7bad3a..e096552e 100644 --- a/src/Subscription/Controller/SubscriberAttributeDefinitionController.php +++ b/src/Subscription/Controller/SubscriberAttributeDefinitionController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager; @@ -32,7 +33,8 @@ public function __construct( RequestValidator $validator, AttributeDefinitionManager $definitionManager, AttributeDefinitionNormalizer $normalizer, - PaginatedDataProvider $paginatedDataProvider + PaginatedDataProvider $paginatedDataProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->definitionManager = $definitionManager; @@ -87,6 +89,7 @@ public function create(Request $request): JsonResponse $definitionRequest = $this->validator->validate($request, CreateAttributeDefinitionRequest::class); $attributeDefinition = $this->definitionManager->create($definitionRequest->getDto()); + $this->entityManager->flush(); $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_CREATED); @@ -154,6 +157,7 @@ public function update( attributeDefinition: $attributeDefinition, attributeDefinitionDto: $definitionRequest->getDto(), ); + $this->entityManager->flush(); $json = $this->normalizer->normalize($attributeDefinition, 'json'); return $this->json($json, Response::HTTP_OK); @@ -209,6 +213,7 @@ public function delete( } $this->definitionManager->delete($attributeDefinition); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Subscription/Controller/SubscriberAttributeValueController.php b/src/Subscription/Controller/SubscriberAttributeValueController.php index 834b0977..24b54209 100644 --- a/src/Subscription/Controller/SubscriberAttributeValueController.php +++ b/src/Subscription/Controller/SubscriberAttributeValueController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberAttributeValueFilter; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -33,7 +34,8 @@ public function __construct( RequestValidator $validator, SubscriberAttributeManager $attributeManager, SubscriberAttributeValueNormalizer $normalizer, - PaginatedDataProvider $paginatedDataProvider + PaginatedDataProvider $paginatedDataProvider, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->attributeManager = $attributeManager; @@ -193,6 +195,7 @@ public function delete( throw $this->createNotFoundException('Subscriber attribute not found.'); } $this->attributeManager->delete($attribute); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -349,6 +352,7 @@ public function getAttributeDefinition( } $attribute = $this->attributeManager->getSubscriberAttribute($subscriber->getId(), $definition->getId()); $this->attributeManager->delete($attribute); + $this->entityManager->flush(); return $this->json( $this->normalizer->normalize($attribute), diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index f053dff8..e144e203 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -4,19 +4,23 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Subscription\Request\CreateSubscriberRequest; use PhpList\RestBundle\Subscription\Request\UpdateSubscriberRequest; -use PhpList\RestBundle\Subscription\Service\SubscriberService; +use PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer; +use PhpList\RestBundle\Subscription\Service\SubscriberHistoryService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -28,16 +32,16 @@ #[Route('/subscribers', name: 'subscriber_')] class SubscriberController extends BaseController { - private SubscriberService $subscriberService; - public function __construct( Authentication $authentication, RequestValidator $validator, - SubscriberService $subscriberService, + private readonly SubscriberManager $subscriberManager, + private readonly SubscriberNormalizer $subscriberNormalizer, + private readonly SubscriberHistoryService $subscriberHistoryService, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->authentication = $authentication; - $this->subscriberService = $subscriberService; } #[Route('', name: 'create', methods: ['POST'])] @@ -93,7 +97,9 @@ public function createSubscriber(Request $request): JsonResponse /** @var CreateSubscriberRequest $subscriberRequest */ $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); - $subscriberData = $this->subscriberService->createSubscriber($subscriberRequest); + $subscriber = $this->subscriberManager->createSubscriber($subscriberRequest->getDto()); + $this->entityManager->flush(); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber, 'json'); return $this->json($subscriberData, Response::HTTP_CREATED); } @@ -163,7 +169,9 @@ public function updateSubscriber( } /** @var UpdateSubscriberRequest $updateSubscriberRequest */ $updateSubscriberRequest = $this->validator->validate($request, UpdateSubscriberRequest::class); - $subscriberData = $this->subscriberService->updateSubscriber($updateSubscriberRequest); + $subscriber = $this->subscriberManager->updateSubscriber($updateSubscriberRequest->getDto(), $admin); + $this->entityManager->flush(); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber, 'json'); return $this->json($subscriberData, Response::HTTP_OK); } @@ -213,7 +221,8 @@ public function getSubscriber(Request $request, int $subscriberId): JsonResponse { $this->requireAuthentication($request); - $subscriberData = $this->subscriberService->getSubscriber($subscriberId); + $subscriber = $this->subscriberManager->getSubscriberById($subscriberId); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber); return $this->json($subscriberData, Response::HTTP_OK); } @@ -309,7 +318,7 @@ public function getSubscriberHistory( ): JsonResponse { $this->requireAuthentication($request); - $historyData = $this->subscriberService->getSubscriberHistory($request, $subscriber); + $historyData = $this->subscriberHistoryService->getSubscriberHistory($request, $subscriber); return $this->json( data: $historyData, @@ -370,7 +379,8 @@ 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); } @@ -438,7 +448,9 @@ public function resetBounceCount( throw $this->createNotFoundException('Subscriber not found.'); } - $subscriberData = $this->subscriberService->resetSubscriberBounceCount($subscriber); + $subscriber = $this->subscriberManager->resetBounceCount($subscriber); + $this->entityManager->flush(); + $subscriberData = $this->subscriberNormalizer->normalize($subscriber, 'json'); return $this->json($subscriberData, Response::HTTP_OK); } @@ -484,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/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index a0ea079f..304c343f 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Controller; +use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager; @@ -32,6 +33,7 @@ class SubscriberListController extends BaseController private SubscriberListNormalizer $normalizer; private SubscriberListManager $subscriberListManager; private PaginatedDataProvider $paginatedDataProvider; + private EntityManagerInterface $entityManager; public function __construct( Authentication $authentication, @@ -39,11 +41,13 @@ public function __construct( SubscriberListNormalizer $normalizer, SubscriberListManager $subscriberListManager, PaginatedDataProvider $paginatedDataProvider, + EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); $this->normalizer = $normalizer; $this->subscriberListManager = $subscriberListManager; $this->paginatedDataProvider = $paginatedDataProvider; + $this->entityManager = $entityManager; } #[Route('', name: 'get_list', methods: ['GET'])] @@ -223,6 +227,7 @@ public function deleteList( } $this->subscriberListManager->delete($list); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } @@ -273,6 +278,7 @@ public function createList(Request $request, SubscriberListNormalizer $normalize /** @var CreateSubscriberListRequest $subscriberListRequest */ $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest->getDto(), $authUser); + $this->entityManager->flush(); return $this->json($normalizer->normalize($data), Response::HTTP_CREATED); } diff --git a/src/Subscription/Service/SubscriberService.php b/src/Subscription/Service/SubscriberService.php deleted file mode 100644 index 2ddce62f..00000000 --- a/src/Subscription/Service/SubscriberService.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ -class SubscriberService -{ - public function __construct( - private readonly SubscriberManager $subscriberManager, - private readonly SubscriberNormalizer $subscriberNormalizer, - private readonly SubscriberHistoryService $subscriberHistoryService, - ) { - } - - public function createSubscriber(CreateSubscriberRequest $subscriberRequest): array - { - $subscriber = $this->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 resetSubscriberBounceCount(Subscriber $subscriber): array - { - $subscriber = $this->subscriberManager->resetBounceCount($subscriber); - return $this->subscriberNormalizer->normalize($subscriber, 'json'); - } - - public function getSubscriber(int $subscriberId): array - { - $subscriber = $this->subscriberManager->getSubscriberById($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/Subscription/Controller/SubscriberControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php index 3ffb4dd2..93cf93d0 100644 --- a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php @@ -72,6 +72,8 @@ public function testPostSubscribersWithValidSessionKeyAndMinimalValidDataReturns self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $responseContent['unique_id']); } + + public function testPostSubscribersWithValidSessionKeyAndValidDataCreatesSubscriber() { $email = 'subscriber@example.com'; diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php index 0a3d2e4a..e328fe97 100644 --- a/tests/Unit/Messaging/Service/CampaignServiceTest.php +++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Tests\Unit\Messaging\Service; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Identity\Model\Privileges; @@ -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/Service/SubscriberServiceTest.php b/tests/Unit/Subscription/Service/SubscriberServiceTest.php deleted file mode 100644 index ffc27dc4..00000000 --- a/tests/Unit/Subscription/Service/SubscriberServiceTest.php +++ /dev/null @@ -1,174 +0,0 @@ -subscriberManager = $this->createMock(SubscriberManager::class); - $this->subscriberNormalizer = $this->createMock(SubscriberNormalizer::class); - $this->subscriberHistoryService = $this->createMock(SubscriberHistoryService::class); - - $this->subscriberService = new SubscriberService( - $this->subscriberManager, - $this->subscriberNormalizer, - $this->subscriberHistoryService - ); - } - - public function testCreateSubscriberReturnsNormalizedSubscriber(): void - { - $subscriberDto = $this->createMock(CreateSubscriberDto::class); - $createSubscriberRequest = $this->createMock(CreateSubscriberRequest::class); - $subscriber = $this->createMock(Subscriber::class); - $expectedResult = ['id' => 1, 'email' => 'test@example.com']; - - $createSubscriberRequest->expects($this->once()) - ->method('getDto') - ->willReturn($subscriberDto); - - $this->subscriberManager->expects($this->once()) - ->method('createSubscriber') - ->with($this->identicalTo($subscriberDto)) - ->willReturn($subscriber); - - $this->subscriberNormalizer->expects($this->once()) - ->method('normalize') - ->with($this->identicalTo($subscriber), 'json') - ->willReturn($expectedResult); - - $result = $this->subscriberService->createSubscriber($createSubscriberRequest); - - $this->assertSame($expectedResult, $result); - } - - public function testUpdateSubscriberReturnsNormalizedSubscriber(): void - { - $subscriberDto = $this->createMock(UpdateSubscriberDto::class); - $updateSubscriberRequest = $this->createMock(UpdateSubscriberRequest::class); - $subscriber = $this->createMock(Subscriber::class); - $expectedResult = ['id' => 1, 'email' => 'updated@example.com']; - - $updateSubscriberRequest->expects($this->once()) - ->method('getDto') - ->willReturn($subscriberDto); - - $this->subscriberManager->expects($this->once()) - ->method('updateSubscriber') - ->with($this->identicalTo($subscriberDto)) - ->willReturn($subscriber); - - $this->subscriberNormalizer->expects($this->once()) - ->method('normalize') - ->with($this->identicalTo($subscriber), 'json') - ->willReturn($expectedResult); - - $result = $this->subscriberService->updateSubscriber($updateSubscriberRequest); - - $this->assertSame($expectedResult, $result); - } - - public function testGetSubscriberReturnsNormalizedSubscriber(): void - { - $subscriberId = 1; - $subscriber = $this->createMock(Subscriber::class); - $expectedResult = ['id' => 1, 'email' => 'test@example.com']; - - $this->subscriberManager->expects($this->once()) - ->method('getSubscriberById') - ->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); - } -} From 58ff1106ed11dc617766b106652a5ddffda9219f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 6 Nov 2025 10:56:33 +0400 Subject: [PATCH 13/18] Core main branch --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 99e5fcad..c4c880f8 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-dev", + "phplist/core": "dev-main", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", From 6d0fbeb696693b29d572561acd3ac3faed4e08a8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 6 Nov 2025 11:33:05 +0400 Subject: [PATCH 14/18] Fix: tests --- .../Controller/SessionControllerTest.php | 2 +- .../Fixtures/AdministratorFixture.php | 8 +++++++ .../Fixtures/AdministratorTokenFixture.php | 5 ++-- .../Fixtures/SubscriberListFixture.php | 24 ++++++++++++++----- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/Integration/Identity/Controller/SessionControllerTest.php b/tests/Integration/Identity/Controller/SessionControllerTest.php index 41f3298a..71a811c6 100644 --- a/tests/Integration/Identity/Controller/SessionControllerTest.php +++ b/tests/Integration/Identity/Controller/SessionControllerTest.php @@ -100,7 +100,7 @@ public function testPostSessionsWithInvalidCredentialsReturnsNotAuthorized() { $this->loadFixtures([AdministratorFixture::class]); - $loginName = 'john.doe'; + $loginName = 'john.doe.1'; $password = 'a sandwich and a cup of coffee'; $jsonData = ['login_name' => $loginName, 'password' => $password]; diff --git a/tests/Integration/Identity/Fixtures/AdministratorFixture.php b/tests/Integration/Identity/Fixtures/AdministratorFixture.php index aa8790f8..0bf1b316 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorFixture.php @@ -29,6 +29,8 @@ public function load(ObjectManager $manager): void $headers = fgetcsv($handle); + $adminRepository = $manager->getRepository(Administrator::class); + do { $data = fgetcsv($handle); if ($data === false) { @@ -36,6 +38,12 @@ public function load(ObjectManager $manager): void } $row = array_combine($headers, $data); + // Make fixture idempotent: skip if admin with this ID already exists + $existing = $adminRepository->find($row['id']); + if ($existing instanceof Administrator) { + continue; + } + $admin = new Administrator(); $this->setSubjectId($admin, (int)$row['id']); $admin->setLoginName($row['loginname']); diff --git a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php index 3a47138f..b0d2eb6c 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php @@ -42,14 +42,15 @@ public function load(ObjectManager $manager): void if ($admin === null) { $admin = new Administrator(); $this->setSubjectId($admin, (int)$row['adminid']); + // Use a deterministic, non-conflicting login name to avoid clashes with other fixtures + $admin->setLoginName('admin_' . $row['adminid']); $admin->setSuperUser(true); $manager->persist($admin); } - $adminToken = new AdministratorToken(); + $adminToken = new AdministratorToken($admin); $this->setSubjectId($adminToken, (int)$row['id']); $adminToken->setKey($row['value']); - $adminToken->setAdministrator($admin); $manager->persist($adminToken); $this->setSubjectProperty($adminToken, 'expiry', new DateTime($row['expires'])); diff --git a/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php index c1c4d110..35fca2d2 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php @@ -31,6 +31,7 @@ public function load(ObjectManager $manager): void $headers = fgetcsv($handle); $adminRepository = $manager->getRepository(Administrator::class); + $adminsById = []; do { $data = fgetcsv($handle); @@ -39,13 +40,24 @@ 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']); - $admin->setSuperUser(true); - $admin->setDisabled(false); - $manager->persist($admin); + // Try to reuse an existing admin with our deterministic login name + $existingByLogin = $adminRepository->findOneBy(['loginName' => 'owner_' . $ownerId]); + if ($existingByLogin instanceof Administrator) { + $admin = $existingByLogin; + $adminsById[$ownerId] = $admin; + } else { + $admin = new Administrator(); + $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(); From c8621bf027633541c63d8b92868b3e6d7e308bdd Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 6 Nov 2025 16:22:43 +0400 Subject: [PATCH 15/18] After review 0 --- .coderabbit.yaml | 16 ++++++++ .../Request/CreateBounceRegexRequest.php | 2 +- .../Controller/SubscribePageController.php | 2 + .../AdminAttributeDefinitionFixture.php | 3 ++ .../Fixtures/AdminAttributeValueFixture.php | 3 ++ .../Fixtures/AdministratorFixture.php | 3 ++ .../Fixtures/AdministratorTokenFixture.php | 3 ++ .../Messaging/Fixtures/MessageFixture.php | 4 ++ .../Messaging/Fixtures/TemplateFixture.php | 3 ++ .../SubscribePageControllerTest.php | 1 + .../Fixtures/SubscribePageFixture.php | 5 ++- .../Fixtures/SubscriberFixture.php | 3 ++ .../Fixtures/SubscriberListFixture.php | 37 +++++++++++-------- .../Fixtures/SubscriptionFixture.php | 3 ++ 14 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..b354963b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +tone_instructions: "chill" +reviews: + profile: "chill" + high_level_summary: true + collapse_walkthrough: true + suggested_labels: false + high_level_summary_in_walkthrough: false + changed_files_summary: false + poem: false + auto_review: + enabled: true + base_branches: + - ".*" + drafts: false diff --git a/src/Messaging/Request/CreateBounceRegexRequest.php b/src/Messaging/Request/CreateBounceRegexRequest.php index 69771191..90cb0e8c 100644 --- a/src/Messaging/Request/CreateBounceRegexRequest.php +++ b/src/Messaging/Request/CreateBounceRegexRequest.php @@ -17,7 +17,7 @@ class CreateBounceRegexRequest implements RequestInterface public ?string $action = null; #[Assert\Type('integer')] - public ?int $listOrder = 0; + public int $listOrder = 0; #[Assert\Type('integer')] public ?int $admin = null; diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 38117c8d..ef7a59c3 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -219,6 +219,7 @@ public function updatePage( active: $updateRequest->active, owner: $admin, ); + $this->entityManager->flush(); return $this->json($this->normalizer->normalize($updated), Response::HTTP_OK); } @@ -273,6 +274,7 @@ public function deletePage( } $this->subscribePageManager->deletePage($page); + $this->entityManager->flush(); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php b/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php index f03bc3f4..05d9233f 100644 --- a/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php +++ b/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php @@ -35,6 +35,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $definition = new AdminAttributeDefinition($row['name']); $this->setSubjectId($definition, (int)$row['id']); diff --git a/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php b/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php index 3f829be8..67af8315 100644 --- a/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php +++ b/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php @@ -40,6 +40,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $admin = $adminRepository->find($row['admin_id']); if ($admin === null) { diff --git a/tests/Integration/Identity/Fixtures/AdministratorFixture.php b/tests/Integration/Identity/Fixtures/AdministratorFixture.php index 0bf1b316..f0445b0a 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorFixture.php @@ -37,6 +37,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } // Make fixture idempotent: skip if admin with this ID already exists $existing = $adminRepository->find($row['id']); diff --git a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php index b0d2eb6c..3d2061b2 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php @@ -37,6 +37,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $admin = $adminRepository->find($row['adminid']); if ($admin === null) { diff --git a/tests/Integration/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Messaging/Fixtures/MessageFixture.php index 0f5891b1..38bc4017 100644 --- a/tests/Integration/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Messaging/Fixtures/MessageFixture.php @@ -45,6 +45,10 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } + $admin = $adminRepository->find($row['owner']); $template = $templateRepository->find($row['template']); diff --git a/tests/Integration/Messaging/Fixtures/TemplateFixture.php b/tests/Integration/Messaging/Fixtures/TemplateFixture.php index 9f26d96a..1da7dd9d 100644 --- a/tests/Integration/Messaging/Fixtures/TemplateFixture.php +++ b/tests/Integration/Messaging/Fixtures/TemplateFixture.php @@ -35,6 +35,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $template = new Template($row['title']); $template->setContent($row['template']); diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index d9da7b15..fa2d541a 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -79,6 +79,7 @@ public function testCreateSubscribePageWithoutSessionReturnsForbidden(): void public function testCreateSubscribePageWithSessionCreatesPage(): void { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); $payload = json_encode([ 'title' => 'new-page@example.org', 'active' => true, diff --git a/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php b/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php index a22b2465..6f5cda77 100644 --- a/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php @@ -38,6 +38,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $owner = $adminRepository->find($row['owner']); if ($owner === null) { @@ -51,7 +54,7 @@ public function load(ObjectManager $manager): void $page = new SubscribePage(); $this->setSubjectId($page, (int)$row['id']); $page->setTitle($row['title']); - $page->setActive((bool)$row['active']); + $page->setActive(filter_var($row['active'], FILTER_VALIDATE_BOOLEAN)); $page->setOwner($owner); $manager->persist($page); diff --git a/tests/Integration/Subscription/Fixtures/SubscriberFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberFixture.php index 6b49d0fc..943405c5 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberFixture.php @@ -36,6 +36,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $subscriber = new Subscriber(); $this->setSubjectId($subscriber, (int)$row['id']); diff --git a/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php index 35fca2d2..da57f643 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 @@ -39,25 +41,21 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $ownerId = (int)$row['owner']; $admin = $adminsById[$ownerId] ?? $adminRepository->find($ownerId); if ($admin === null) { - // Try to reuse an existing admin with our deterministic login name - $existingByLogin = $adminRepository->findOneBy(['loginName' => 'owner_' . $ownerId]); - if ($existingByLogin instanceof Administrator) { - $admin = $existingByLogin; - $adminsById[$ownerId] = $admin; - } else { - $admin = new Administrator(); - $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; - } + $admin = new Administrator(); + $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(); @@ -78,4 +76,11 @@ public function load(ObjectManager $manager): void fclose($handle); } + + public function getDependencies(): array + { + return [ + AdministratorFixture::class, + ]; + } } diff --git a/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php b/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php index 8ff65f3d..1f6476e8 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php @@ -41,6 +41,9 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); + if ($row === false) { + throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); + } $subscriber = $subscriberRepository->find((int)$row['userid']); $subscriberList = $subscriberListRepository->find((int)$row['listid']); From 6bc6aae92c7e1cd260d3860e12052534022e3151 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 6 Nov 2025 16:40:12 +0400 Subject: [PATCH 16/18] Ai review --- .github/PULL_REQUEST_TEMPLATE.md | 36 +------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b4c9b3f7..aad3619f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,37 +1,3 @@ -### Summary - -Provide a general description of the code changes in your pull request … -were there any bugs you had fixed? If so, mention them. If these bugs have open -GitHub issues, be sure to tag them here as well, to keep the conversation -linked together. - - -### Unit test - -Are your changes covered with unit tests, and do they not break anything? - -You can run the existing unit tests using this command: - - vendor/bin/phpunit tests/ - - -### Code style - -Have you checked that you code is well-documented and follows the PSR-2 coding -style? - -You can check for this using this command: - - vendor/bin/phpcs --standard=PSR2 src/ tests/ - - -### Other Information - -If there's anything else that's important and relevant to your pull -request, mention that information here. This could include benchmarks, -or other information. - -If you are updating any of the CHANGELOG files or are asked to update the -CHANGELOG files by reviewers, please add the CHANGELOG entry at the top of the file. +"@coderabbitai summary" Thanks for contributing to phpList! From 9ad74915cfcf6ea24e6b27db69981f2219c6e4c3 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 6 Nov 2025 16:45:40 +0400 Subject: [PATCH 17/18] Style fix --- src/Identity/Request/CreateSessionRequest.php | 4 ++-- src/Identity/Request/UpdateAttributeDefinitionRequest.php | 2 +- src/Identity/Request/ValidateTokenRequest.php | 2 +- src/Messaging/Request/CreateTemplateRequest.php | 7 ++++--- src/Subscription/Controller/SubscriberImportController.php | 2 +- src/Subscription/Request/SubscribePageRequest.php | 2 +- .../Identity/Fixtures/AdminAttributeDefinitionFixture.php | 3 --- .../Identity/Fixtures/AdminAttributeValueFixture.php | 3 --- .../Integration/Identity/Fixtures/AdministratorFixture.php | 3 --- .../Identity/Fixtures/AdministratorTokenFixture.php | 3 --- tests/Integration/Messaging/Fixtures/MessageFixture.php | 3 --- tests/Integration/Messaging/Fixtures/TemplateFixture.php | 3 --- .../Subscription/Fixtures/SubscribePageFixture.php | 3 --- .../Subscription/Fixtures/SubscriberFixture.php | 3 --- .../Subscription/Fixtures/SubscriberListFixture.php | 3 --- .../Subscription/Fixtures/SubscriptionFixture.php | 3 --- 16 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/Identity/Request/CreateSessionRequest.php b/src/Identity/Request/CreateSessionRequest.php index fb61b094..abff868a 100644 --- a/src/Identity/Request/CreateSessionRequest.php +++ b/src/Identity/Request/CreateSessionRequest.php @@ -9,11 +9,11 @@ class CreateSessionRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\Type(type: 'string')] public string $loginName; - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\Type(type: 'string')] public string $password; diff --git a/src/Identity/Request/UpdateAttributeDefinitionRequest.php b/src/Identity/Request/UpdateAttributeDefinitionRequest.php index 7313a905..8f387392 100644 --- a/src/Identity/Request/UpdateAttributeDefinitionRequest.php +++ b/src/Identity/Request/UpdateAttributeDefinitionRequest.php @@ -10,7 +10,7 @@ class UpdateAttributeDefinitionRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] public string $name; public ?string $type = null; diff --git a/src/Identity/Request/ValidateTokenRequest.php b/src/Identity/Request/ValidateTokenRequest.php index 481b95fc..ffeb0f84 100644 --- a/src/Identity/Request/ValidateTokenRequest.php +++ b/src/Identity/Request/ValidateTokenRequest.php @@ -9,7 +9,7 @@ class ValidateTokenRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\Type(type: 'string')] public string $token; diff --git a/src/Messaging/Request/CreateTemplateRequest.php b/src/Messaging/Request/CreateTemplateRequest.php index c5bbbcb8..9ab4c6c8 100644 --- a/src/Messaging/Request/CreateTemplateRequest.php +++ b/src/Messaging/Request/CreateTemplateRequest.php @@ -6,20 +6,21 @@ use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto; use PhpList\RestBundle\Common\Request\RequestInterface; +use PhpList\RestBundle\Messaging\Validator\Constraint\ContainsPlaceholder; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Validator\Constraints as Assert; class CreateTemplateRequest implements RequestInterface { - #[Assert\NotBlank] + #[Assert\NotBlank(normalizer: 'trim')] #[Assert\NotNull] public string $title; #[Assert\NotBlank] - #[\PhpList\RestBundle\Messaging\Validator\Constraint\ContainsPlaceholder] + #[ContainsPlaceholder] public string $content; - #[\PhpList\RestBundle\Messaging\Validator\Constraint\ContainsPlaceholder] + #[ContainsPlaceholder] public ?string $text = null; public ?UploadedFile $file = null; diff --git a/src/Subscription/Controller/SubscriberImportController.php b/src/Subscription/Controller/SubscriberImportController.php index 44fc3353..1e4983fc 100644 --- a/src/Subscription/Controller/SubscriberImportController.php +++ b/src/Subscription/Controller/SubscriberImportController.php @@ -147,7 +147,7 @@ public function importSubscribers(Request $request): JsonResponse ]); } catch (CouldNotReadUploadedFileException $exception) { return $this->json([ - 'message' => 'Could not read uploaded file.' . $exception->getMessage() + 'message' => 'Could not read uploaded file. ' . $exception->getMessage() ], Response::HTTP_BAD_REQUEST); } catch (Exception $e) { return $this->json([ diff --git a/src/Subscription/Request/SubscribePageRequest.php b/src/Subscription/Request/SubscribePageRequest.php index 4a89877e..cf22d20c 100644 --- a/src/Subscription/Request/SubscribePageRequest.php +++ b/src/Subscription/Request/SubscribePageRequest.php @@ -10,7 +10,7 @@ class SubscribePageRequest implements RequestInterface { #[Assert\NotBlank] - #[Assert\Email] + #[Assert\Length(min: 1, max: 255)] public string $title; #[Assert\Type(type: 'bool')] diff --git a/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php b/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php index 05d9233f..f03bc3f4 100644 --- a/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php +++ b/tests/Integration/Identity/Fixtures/AdminAttributeDefinitionFixture.php @@ -35,9 +35,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $definition = new AdminAttributeDefinition($row['name']); $this->setSubjectId($definition, (int)$row['id']); diff --git a/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php b/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php index 67af8315..3f829be8 100644 --- a/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php +++ b/tests/Integration/Identity/Fixtures/AdminAttributeValueFixture.php @@ -40,9 +40,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $admin = $adminRepository->find($row['admin_id']); if ($admin === null) { diff --git a/tests/Integration/Identity/Fixtures/AdministratorFixture.php b/tests/Integration/Identity/Fixtures/AdministratorFixture.php index f0445b0a..0bf1b316 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorFixture.php @@ -37,9 +37,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } // Make fixture idempotent: skip if admin with this ID already exists $existing = $adminRepository->find($row['id']); diff --git a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php index 3d2061b2..b0d2eb6c 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorTokenFixture.php @@ -37,9 +37,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $admin = $adminRepository->find($row['adminid']); if ($admin === null) { diff --git a/tests/Integration/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Messaging/Fixtures/MessageFixture.php index 38bc4017..42b33b51 100644 --- a/tests/Integration/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Messaging/Fixtures/MessageFixture.php @@ -45,9 +45,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $admin = $adminRepository->find($row['owner']); $template = $templateRepository->find($row['template']); diff --git a/tests/Integration/Messaging/Fixtures/TemplateFixture.php b/tests/Integration/Messaging/Fixtures/TemplateFixture.php index 1da7dd9d..9f26d96a 100644 --- a/tests/Integration/Messaging/Fixtures/TemplateFixture.php +++ b/tests/Integration/Messaging/Fixtures/TemplateFixture.php @@ -35,9 +35,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $template = new Template($row['title']); $template->setContent($row['template']); diff --git a/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php b/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php index 6f5cda77..c0cf3496 100644 --- a/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscribePageFixture.php @@ -38,9 +38,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $owner = $adminRepository->find($row['owner']); if ($owner === null) { diff --git a/tests/Integration/Subscription/Fixtures/SubscriberFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberFixture.php index 943405c5..6b49d0fc 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberFixture.php @@ -36,9 +36,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $subscriber = new Subscriber(); $this->setSubjectId($subscriber, (int)$row['id']); diff --git a/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php index da57f643..3875e045 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberListFixture.php @@ -41,9 +41,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $ownerId = (int)$row['owner']; $admin = $adminsById[$ownerId] ?? $adminRepository->find($ownerId); diff --git a/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php b/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php index 1f6476e8..8ff65f3d 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriptionFixture.php @@ -41,9 +41,6 @@ public function load(ObjectManager $manager): void break; } $row = array_combine($headers, $data); - if ($row === false) { - throw new RuntimeException('Malformed CSV data: header/data length mismatch.'); - } $subscriber = $subscriberRepository->find((int)$row['userid']); $subscriberList = $subscriberListRepository->find((int)$row['listid']); From bd5bf9576794ab45cb32f9250403ad775da50249 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 8 Nov 2025 16:29:58 +0400 Subject: [PATCH 18/18] validateRegexPattern --- .../Request/CreateBounceRegexRequest.php | 21 ++++++++++++ .../Request/CreateBounceRegexRequestTest.php | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/Messaging/Request/CreateBounceRegexRequest.php b/src/Messaging/Request/CreateBounceRegexRequest.php index 90cb0e8c..dfbee9a3 100644 --- a/src/Messaging/Request/CreateBounceRegexRequest.php +++ b/src/Messaging/Request/CreateBounceRegexRequest.php @@ -6,6 +6,7 @@ use PhpList\RestBundle\Common\Request\RequestInterface; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; class CreateBounceRegexRequest implements RequestInterface { @@ -39,4 +40,24 @@ public function getDto(): array 'status' => $this->status, ]; } + + #[Assert\Callback('validateRegexPattern')] + public function validateRegexPattern(ExecutionContextInterface $context): void + { + if (!isset($this->regex)) { + return; + } + set_error_handler(static function () { + return true; + }); + // phpcs:ignore Generic.PHP.NoSilencedErrors + $allGood = @preg_match($this->regex, ''); + restore_error_handler(); + + if ($allGood === false) { + $context->buildViolation('Invalid regular expression pattern.') + ->atPath('regex') + ->addViolation(); + } + } } diff --git a/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php index 8767477f..03b32d8f 100644 --- a/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php +++ b/tests/Unit/Messaging/Request/CreateBounceRegexRequestTest.php @@ -6,6 +6,8 @@ use PhpList\RestBundle\Messaging\Request\CreateBounceRegexRequest; use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface; class CreateBounceRegexRequestTest extends TestCase { @@ -43,4 +45,36 @@ public function testGetDtoWithDefaults(): void $this->assertNull($dto['comment']); $this->assertNull($dto['status']); } + + public function testValidateRegexPatternWithValidRegexDoesNotAddViolation(): void + { + $req = new CreateBounceRegexRequest(); + $req->regex = '/valid.*/i'; + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->never())->method('buildViolation'); + + $req->validateRegexPattern($context); + + // if no exception and no violation calls, the test passes + $this->assertTrue(true); + } + + public function testValidateRegexPatternWithInvalidRegexAddsViolation(): void + { + $req = new CreateBounceRegexRequest(); + $req->regex = '/[invalid'; + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('atPath')->with('regex')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $context = $this->createMock(ExecutionContextInterface::class); + $context->expects($this->once()) + ->method('buildViolation') + ->with('Invalid regular expression pattern.') + ->willReturn($builder); + + $req->validateRegexPattern($context); + } }