From 740f8783fb073d750b92e177696eb4c334a30b77 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 13 Jun 2025 20:44:45 +0400 Subject: [PATCH 01/20] ISSUE-345: admin privileges --- .../OpenApi/SwaggerSchemasRequest.php | 24 +++++++++ .../Request/CreateAdministratorRequest.php | 22 ++++++-- .../Request/UpdateAdministratorRequest.php | 14 +++++ .../Serializer/AdministratorNormalizer.php | 1 + .../AdministratorControllerTest.php | 54 +++++++++++++++++++ .../CreateAdministratorRequestTest.php | 13 +++++ .../UpdateAdministratorRequestTest.php | 13 +++++ .../AdministratorNormalizerTest.php | 13 +++++ 8 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/Identity/OpenApi/SwaggerSchemasRequest.php b/src/Identity/OpenApi/SwaggerSchemasRequest.php index bc1f096..d0421ec 100644 --- a/src/Identity/OpenApi/SwaggerSchemasRequest.php +++ b/src/Identity/OpenApi/SwaggerSchemasRequest.php @@ -36,6 +36,18 @@ type: 'boolean', example: false ), + new OA\Property( + property: 'privileges', + description: 'Array of privileges where keys are privilege names and values are booleans', + properties: [ + new OA\Property(property: 'subscribers', type: 'boolean', example: true), + new OA\Property(property: 'campaigns', type: 'boolean', example: false), + new OA\Property(property: 'statistics', type: 'boolean', example: true), + new OA\Property(property: 'settings', type: 'boolean', example: false), + ], + type: 'object', + example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + ), ], type: 'object' )] @@ -68,6 +80,18 @@ type: 'boolean', example: false ), + new OA\Property( + property: 'privileges', + description: 'Array of privileges where keys are privilege names and values are booleans', + properties: [ + new OA\Property(property: 'subscribers', type: 'boolean', example: true), + new OA\Property(property: 'campaigns', type: 'boolean', example: false), + new OA\Property(property: 'statistics', type: 'boolean', example: true), + new OA\Property(property: 'settings', type: 'boolean', example: false), + ], + type: 'object', + example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + ), ], type: 'object' )] diff --git a/src/Identity/Request/CreateAdministratorRequest.php b/src/Identity/Request/CreateAdministratorRequest.php index 921bdda..f69c70c 100644 --- a/src/Identity/Request/CreateAdministratorRequest.php +++ b/src/Identity/Request/CreateAdministratorRequest.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\RestBundle\Common\Request\RequestInterface; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmail; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueLoginName; @@ -31,13 +32,26 @@ class CreateAdministratorRequest implements RequestInterface #[Assert\Type('bool')] public bool $superUser = false; + /** + * Array of privileges where keys are privilege names (from PrivilegeFlag enum) and values are booleans. + * Example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + */ + #[Assert\Type('array')] + #[Assert\All([ + 'constraints' => [ + new Assert\Type(['type' => 'bool']), + ], + ])] + public array $privileges = []; + public function getDto(): CreateAdministratorDto { return new CreateAdministratorDto( - $this->loginName, - $this->password, - $this->email, - $this->superUser + loginName: $this->loginName, + password: $this->password, + email: $this->email, + isSuperUser: $this->superUser, + privileges: $this->privileges ); } } diff --git a/src/Identity/Request/UpdateAdministratorRequest.php b/src/Identity/Request/UpdateAdministratorRequest.php index ce3b8d3..de9d725 100644 --- a/src/Identity/Request/UpdateAdministratorRequest.php +++ b/src/Identity/Request/UpdateAdministratorRequest.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\UpdateAdministratorDto; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\RestBundle\Common\Request\RequestInterface; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmail; use PhpList\RestBundle\Identity\Validator\Constraint\UniqueLoginName; @@ -29,6 +30,18 @@ class UpdateAdministratorRequest implements RequestInterface #[Assert\Type('bool')] public ?bool $superAdmin = null; + /** + * Array of privileges where keys are privilege names (from PrivilegeFlag enum) and values are booleans. + * Example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] + */ + #[Assert\Type('array')] + #[Assert\All([ + 'constraints' => [ + new Assert\Type(['type' => 'bool']), + ], + ])] + public array $privileges = []; + public function getDto(): UpdateAdministratorDto { return new UpdateAdministratorDto( @@ -37,6 +50,7 @@ public function getDto(): UpdateAdministratorDto password: $this->password, email: $this->email, superAdmin: $this->superAdmin, + privileges: $this->privileges ); } } diff --git a/src/Identity/Serializer/AdministratorNormalizer.php b/src/Identity/Serializer/AdministratorNormalizer.php index 35f33ec..5382bf1 100644 --- a/src/Identity/Serializer/AdministratorNormalizer.php +++ b/src/Identity/Serializer/AdministratorNormalizer.php @@ -26,6 +26,7 @@ public function normalize($object, string $format = null, array $context = []): 'login_name' => $object->getLoginName(), 'email' => $object->getEmail(), 'super_admin' => $object->isSuperUser(), + 'privileges' => $object->getPrivileges()->all(), 'created_at' => $object->getCreatedAt()?->format(DateTimeInterface::ATOM), ]; } diff --git a/tests/Integration/Identity/Controller/AdministratorControllerTest.php b/tests/Integration/Identity/Controller/AdministratorControllerTest.php index b9fa249..e229917 100644 --- a/tests/Integration/Identity/Controller/AdministratorControllerTest.php +++ b/tests/Integration/Identity/Controller/AdministratorControllerTest.php @@ -60,11 +60,24 @@ public function testCreateAdministratorWithValidDataReturnsCreated(): void 'loginName' => 'new.admin', 'password' => 'NewPassword123!', 'email' => 'new.admin@example.com', + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], ])); $this->assertHttpCreated(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('new.admin', $data['login_name']); + + $administrator = $this->administratorRepository->findOneBy(['loginName' => 'new.admin']); + $privileges = $administrator->getPrivileges()->all(); + self::assertTrue($privileges['subscribers']); + self::assertFalse($privileges['campaigns']); + self::assertTrue($privileges['statistics']); + self::assertFalse($privileges['settings']); } public function testUpdateAdministratorReturnsOk(): void @@ -73,11 +86,24 @@ public function testUpdateAdministratorReturnsOk(): void $this->authenticatedJsonRequest('put', '/api/v2/administrators/1', [], [], [], json_encode([ 'email' => 'updated@example.com', + 'privileges' => [ + 'subscribers' => false, + 'campaigns' => true, + 'statistics' => false, + 'settings' => true, + ], ])); $this->assertHttpOkay(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('updated@example.com', $data['email']); + + $administrator = $this->administratorRepository->find(1); + $privileges = $administrator->getPrivileges()->all(); + self::assertFalse($privileges['subscribers']); + self::assertTrue($privileges['campaigns']); + self::assertFalse($privileges['statistics']); + self::assertTrue($privileges['settings']); } public function testDeleteAdministratorReturnsNoContent(): void @@ -116,4 +142,32 @@ public function testPutAdministratorWithInvalidIdReturns404(): void $this->assertHttpNotFound(); } + + public function testUpdateAdministratorPrivilegesOnly(): void + { + $this->loadFixtures([AdministratorFixture::class]); + + $originalAdmin = $this->administratorRepository->find(1); + $originalEmail = $originalAdmin->getEmail(); + + $this->authenticatedJsonRequest('put', '/api/v2/administrators/1', [], [], [], json_encode([ + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => true, + 'statistics' => true, + 'settings' => true, + ], + ])); + + $this->assertHttpOkay(); + + $updatedAdmin = $this->administratorRepository->find(1); + self::assertSame($originalEmail, $updatedAdmin->getEmail()); + + $privileges = $updatedAdmin->getPrivileges()->all(); + self::assertTrue($privileges['subscribers']); + self::assertTrue($privileges['campaigns']); + self::assertTrue($privileges['statistics']); + self::assertTrue($privileges['settings']); + } } diff --git a/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php b/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php index 6d43406..fe54dda 100644 --- a/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php +++ b/tests/Unit/Identity/Request/CreateAdministratorRequestTest.php @@ -16,6 +16,12 @@ public function testGetDtoReturnsCorrectDto(): void $request->password = 'password123'; $request->email = 'test@example.com'; $request->superUser = true; + $request->privileges = [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ]; $dto = $request->getDto(); @@ -23,6 +29,12 @@ public function testGetDtoReturnsCorrectDto(): void $this->assertEquals('password123', $dto->password); $this->assertEquals('test@example.com', $dto->email); $this->assertTrue($dto->isSuperUser); + $this->assertEquals([ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], $dto->privileges); } public function testGetDtoWithDefaultSuperUserValue(): void @@ -38,5 +50,6 @@ public function testGetDtoWithDefaultSuperUserValue(): void $this->assertEquals('password123', $dto->password); $this->assertEquals('test@example.com', $dto->email); $this->assertFalse($dto->isSuperUser); + $this->assertEquals([], $dto->privileges); } } diff --git a/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php b/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php index 5bdbc1d..3eb5228 100644 --- a/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php +++ b/tests/Unit/Identity/Request/UpdateAdministratorRequestTest.php @@ -18,6 +18,12 @@ public function testGetDtoReturnsCorrectDto(): void $request->password = 'password123'; $request->email = 'test@example.com'; $request->superAdmin = true; + $request->privileges = [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ]; $dto = $request->getDto(); @@ -26,6 +32,12 @@ public function testGetDtoReturnsCorrectDto(): void $this->assertEquals('password123', $dto->password); $this->assertEquals('test@example.com', $dto->email); $this->assertTrue($dto->superAdmin); + $this->assertEquals([ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], $dto->privileges); } public function testGetDtoWithNullValues(): void @@ -40,5 +52,6 @@ public function testGetDtoWithNullValues(): void $this->assertNull($dto->password); $this->assertNull($dto->email); $this->assertNull($dto->superAdmin); + $this->assertEquals([], $dto->privileges); } } diff --git a/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php b/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php index 0fa075a..512fa5c 100644 --- a/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php +++ b/tests/Unit/Identity/Serializer/AdministratorNormalizerTest.php @@ -7,6 +7,7 @@ use DateTime; use InvalidArgumentException; use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\Privileges; use PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer; use PHPUnit\Framework\TestCase; @@ -20,6 +21,12 @@ public function testNormalizeValidAdministrator(): void $admin->method('getEmail')->willReturn('admin@example.com'); $admin->method('isSuperUser')->willReturn(true); $admin->method('getCreatedAt')->willReturn(new DateTime('2024-01-01T10:00:00+00:00')); + $admin->method('getPrivileges')->willReturn(new Privileges([ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ])); $normalizer = new AdministratorNormalizer(); $data = $normalizer->normalize($admin); @@ -30,6 +37,12 @@ public function testNormalizeValidAdministrator(): void 'login_name' => 'admin', 'email' => 'admin@example.com', 'super_admin' => true, + 'privileges' => [ + 'subscribers' => true, + 'campaigns' => false, + 'statistics' => true, + 'settings' => false, + ], 'created_at' => '2024-01-01T10:00:00+00:00', ], $data); } From 5c2dc529bff6b595a7572ff6e8b3735d13bb3cce Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 13 Jun 2025 20:45:10 +0400 Subject: [PATCH 02/20] ISSUE-345: dev version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 890aafb..5f8833c 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "v5.0.0-alpha7", + "phplist/core": "dev-ISSUE-345", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", From aff2abf874d43ca2c23dd79ef8159a037a2496ca Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 15 Jun 2025 22:17:36 +0400 Subject: [PATCH 03/20] ISSUE-345: check manage subscribers privilege --- .../Controller/SubscriberController.php | 16 +++++++++++++--- .../Controller/SubscriberImportController.php | 6 +++++- .../Identity/Fixtures/Administrator.csv | 6 +++--- .../Identity/Fixtures/AdministratorFixture.php | 4 ++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Subscription/Controller/SubscriberController.php b/src/Subscription/Controller/SubscriberController.php index bad2924..717f80b 100644 --- a/src/Subscription/Controller/SubscriberController.php +++ b/src/Subscription/Controller/SubscriberController.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Subscription\Controller; 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; @@ -89,7 +90,10 @@ public function __construct( )] public function createSubscriber(Request $request): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to create subscribers.'); + } /** @var CreateSubscriberRequest $subscriberRequest */ $subscriberRequest = $this->validator->validate($request, CreateSubscriberRequest::class); @@ -156,7 +160,10 @@ public function updateSubscriber( Request $request, #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, ): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to update subscribers.'); + } if (!$subscriber) { throw $this->createNotFoundException('Subscriber not found.'); @@ -262,7 +269,10 @@ public function deleteSubscriber( Request $request, #[MapEntity(mapping: ['subscriberId' => 'id'])] ?Subscriber $subscriber = null, ): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to delete subscribers.'); + } if (!$subscriber) { throw $this->createNotFoundException('Subscriber not found.'); diff --git a/src/Subscription/Controller/SubscriberImportController.php b/src/Subscription/Controller/SubscriberImportController.php index dcc60d7..40f31d7 100644 --- a/src/Subscription/Controller/SubscriberImportController.php +++ b/src/Subscription/Controller/SubscriberImportController.php @@ -6,6 +6,7 @@ use Exception; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter; use PhpList\Core\Security\Authentication; @@ -106,7 +107,10 @@ public function __construct( )] public function importSubscribers(Request $request): JsonResponse { - $this->requireAuthentication($request); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to create subscribers.'); + } /** @var UploadedFile|null $file */ $file = $request->files->get('file'); diff --git a/tests/Integration/Identity/Fixtures/Administrator.csv b/tests/Integration/Identity/Fixtures/Administrator.csv index 3232667..9ae9fc8 100644 --- a/tests/Integration/Identity/Fixtures/Administrator.csv +++ b/tests/Integration/Identity/Fixtures/Administrator.csv @@ -1,3 +1,3 @@ -id,loginname,email,created,modified,password,passwordchanged,disabled,superuser -1,"john.doe","john@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3c","2017-06-28",0,1 -2,"jane.doe","jane@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3d","2017-06-28",0,1 +id,loginname,email,created,modified,password,passwordchanged,disabled,superuser,privileges +1,"john.doe","john@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3c","2017-06-28",0,1,a:4:{s:11:"subscribers";b:1;s:9:"campaigns";b:1;s:10:"statistics";b:1;s:8:"settings";b:1;} +2,"jane.doe","jane@example.com","2017-06-22 15:01:17","2017-06-23 19:50:43","1491a3c7e7b23b9a6393323babbb095dee0d7d81b2199617b487bd0fb5236f3d","2017-06-28",0,1, diff --git a/tests/Integration/Identity/Fixtures/AdministratorFixture.php b/tests/Integration/Identity/Fixtures/AdministratorFixture.php index 16be665..aa8790f 100644 --- a/tests/Integration/Identity/Fixtures/AdministratorFixture.php +++ b/tests/Integration/Identity/Fixtures/AdministratorFixture.php @@ -43,6 +43,10 @@ public function load(ObjectManager $manager): void $admin->setPasswordHash($row['password']); $admin->setDisabled((bool) $row['disabled']); $admin->setSuperUser((bool) $row['superuser']); + $privileges = unserialize($row['privileges']); + if ($privileges) { + $admin->setPrivilegesFromArray(unserialize($row['privileges'])); + } $manager->persist($admin); From 6080ba386ed0bb97e37c58afa5e9b907834633c6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 15 Jun 2025 22:20:46 +0400 Subject: [PATCH 04/20] ISSUE-345: check manage campaigns privilege --- src/Messaging/Controller/CampaignController.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index d4e74f9..eb2a22f 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Messaging\Controller; use OpenApi\Attributes as OA; +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; @@ -219,6 +220,9 @@ public function getMessage( public function createMessage(Request $request, MessageNormalizer $normalizer): JsonResponse { $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw $this->createAccessDeniedException('You are not allowed to create campaigns.'); + } /** @var CreateMessageRequest $createMessageRequest */ $createMessageRequest = $this->validator->validate($request, CreateMessageRequest::class); @@ -290,6 +294,9 @@ public function updateMessage( #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, ): JsonResponse { $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw $this->createAccessDeniedException('You are not allowed to update campaigns.'); + } if (!$message) { throw $this->createNotFoundException('Campaign not found.'); @@ -348,7 +355,10 @@ public function deleteMessage( Request $request, #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null ): JsonResponse { - $this->requireAuthentication($request); + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw $this->createAccessDeniedException('You are not allowed to delete campaigns.'); + } if (!$message) { throw $this->createNotFoundException('Campaign not found.'); From 19bed91e7d5cea2992565812bb240770ebe2cb77 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 15 Jun 2025 22:26:23 +0400 Subject: [PATCH 05/20] ISSUE-345: analytics controller --- composer.json | 5 + config/services/controllers.yml | 7 + config/services/managers.yml | 13 + .../Controller/AnalyticsController.php | 439 ++++++++++++++++++ .../Controller/AnalyticsControllerTest.php | 169 +++++++ .../Controller/AnalyticsControllerTest.php | 400 ++++++++++++++++ 6 files changed, 1033 insertions(+) create mode 100644 src/Statistics/Controller/AnalyticsController.php create mode 100644 tests/Integration/Statistics/Controller/AnalyticsControllerTest.php create mode 100644 tests/Unit/Statistics/Controller/AnalyticsControllerTest.php diff --git a/composer.json b/composer.json index 5f8833c..90eacec 100644 --- a/composer.json +++ b/composer.json @@ -111,6 +111,11 @@ "resource": "@PhpListRestBundle/Messaging/Controller/", "type": "attribute", "prefix": "/api/v2" + }, + "rest-api-analitics": { + "resource": "@PhpListRestBundle/Statistics/Controller/", + "type": "attribute", + "prefix": "/api/v2" } } } diff --git a/config/services/controllers.yml b/config/services/controllers.yml index 857146f..9f7566e 100644 --- a/config/services/controllers.yml +++ b/config/services/controllers.yml @@ -24,3 +24,10 @@ services: autowire: true autoconfigure: true public: true + + PhpList\RestBundle\Statistics\Controller\: + resource: '../src/Statistics/Controller' + tags: [ 'controller.service_arguments' ] + autowire: true + autoconfigure: true + public: true diff --git a/config/services/managers.yml b/config/services/managers.yml index eeb4958..aa0da43 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -39,3 +39,16 @@ services: PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: autowire: true autoconfigure: true + + PhpList\Core\Domain\Analytics\Service\Manager\LinkTrackManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Analytics\Service\Manager\UserMessageViewManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Analytics\Service\AnalyticsService: + autowire: true + autoconfigure: true + diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php new file mode 100644 index 0000000..52ce260 --- /dev/null +++ b/src/Statistics/Controller/AnalyticsController.php @@ -0,0 +1,439 @@ +analyticsService = $analyticsService; + } + + #[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])] + #[OA\Get( + path: '/analytics/campaigns', + description: 'Returns statistics overview for campaigns.', + summary: 'Gets campaign statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'limit', + description: 'Maximum number of campaigns to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + ), + new OA\Parameter( + name: 'last_id', + description: 'Last seen campaign ID for pagination', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 0, minimum: 0) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'campaigns', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'campaignId', type: 'integer'), + new OA\Property(property: 'subject', type: 'string'), + new OA\Property(property: 'dateSent', type: 'string', format: 'date-time'), + new OA\Property(property: 'sent', type: 'integer'), + new OA\Property(property: 'bounces', type: 'integer'), + new OA\Property(property: 'forwards', type: 'integer'), + new OA\Property(property: 'uniqueViews', type: 'integer'), + new OA\Property(property: 'totalClicks', type: 'integer'), + new OA\Property(property: 'uniqueClicks', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + new OA\Property(property: 'hasMore', type: 'boolean'), + new OA\Property(property: 'lastId', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getCampaignStatistics(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', 50); + $lastId = (int) $request->query->get('last_id', 0); + + $data = $this->analyticsService->getCampaignStatistics($limit, $lastId); + + return $this->json($data, Response::HTTP_OK); + } + + #[Route('/view-opens', name: 'view_opens_statistics', methods: ['GET'])] + #[OA\Get( + path: '/analytics/view-opens', + description: 'Returns statistics for view opens.', + summary: 'Gets view opens statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'limit', + description: 'Maximum number of campaigns to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + ), + new OA\Parameter( + name: 'last_id', + description: 'Last seen campaign ID for pagination', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 0, minimum: 0) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'campaigns', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'campaignId', type: 'integer'), + new OA\Property(property: 'subject', type: 'string'), + new OA\Property(property: 'sent', type: 'integer'), + new OA\Property(property: 'uniqueViews', type: 'integer'), + new OA\Property(property: 'rate', type: 'number', format: 'float'), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + new OA\Property(property: 'hasMore', type: 'boolean'), + new OA\Property(property: 'lastId', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getViewOpensStatistics(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', 50); + $lastId = (int) $request->query->get('last_id', 0); + + $data = $this->analyticsService->getViewOpensStatistics($limit, $lastId); + + return $this->json($data, Response::HTTP_OK); + } + + #[Route('/domains/top', name: 'top_domains', methods: ['GET'])] + #[OA\Get( + path: '/analytics/domains/top', + description: 'Returns statistics for the top domains with more than 5 subscribers.', + summary: 'Gets top domains statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'limit', + description: 'Maximum number of domains to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + ), + new OA\Parameter( + name: 'min_subscribers', + description: 'Minimum number of subscribers per domain', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 5, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'domains', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'domain', type: 'string'), + new OA\Property(property: 'subscribers', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getTopDomains(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', 50); + $minSubscribers = (int) $request->query->get('min_subscribers', 5); + + $data = $this->analyticsService->getTopDomains($limit, $minSubscribers); + + return $this->json($data, Response::HTTP_OK); + } + + #[Route('/domains/confirmation', name: 'domain_confirmation_statistics', methods: ['GET'])] + #[OA\Get( + path: '/analytics/domains/confirmation', + description: 'Returns statistics for domains showing confirmation status.', + summary: 'Gets domain confirmation statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'limit', + description: 'Maximum number of domains to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'domains', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'domain', type: 'string'), + new OA\Property( + property: 'confirmed', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + new OA\Property( + property: 'unconfirmed', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + new OA\Property( + property: 'blacklisted', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + new OA\Property( + property: 'total', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getDomainConfirmationStatistics(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', 50); + + $data = $this->analyticsService->getDomainConfirmationStatistics($limit); + + return $this->json($data, Response::HTTP_OK); + } + + #[Route('/local-parts/top', name: 'top_local_parts', methods: ['GET'])] + #[OA\Get( + path: '/analytics/local-parts/top', + description: 'Returns statistics for the top local-parts of email addresses.', + summary: 'Gets top local-parts statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'session', + description: 'Session ID obtained from authentication', + in: 'header', + required: true, + schema: new OA\Schema( + type: 'string' + ) + ), + new OA\Parameter( + name: 'limit', + description: 'Maximum number of local-parts to return', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'localParts', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'localPart', type: 'string'), + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Unauthorized', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getTopLocalParts(Request $request): JsonResponse + { + $authUser = $this->requireAuthentication($request); + if (!$authUser->getPrivileges()->has(PrivilegeFlag::Statistics)) { + throw $this->createAccessDeniedException('You are not allowed to access statistics.'); + } + + $limit = (int) $request->query->get('limit', 25); + + $data = $this->analyticsService->getTopLocalParts($limit); + + return $this->json($data, Response::HTTP_OK); + } +} diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php new file mode 100644 index 0000000..0ae4b8f --- /dev/null +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -0,0 +1,169 @@ +get(AnalyticsController::class)); + } + + public function testGetCampaignStatisticsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/campaigns'); + $this->assertHttpForbidden(); + } + + public function testGetCampaignStatisticsWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/analytics/campaigns', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testGetCampaignStatisticsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/campaigns'); + $this->assertHttpOkay(); + } + + public function testGetCampaignStatisticsReturnsCampaignData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/campaigns'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('campaigns', $response); + self::assertArrayHasKey('total', $response); + self::assertArrayHasKey('hasMore', $response); + self::assertArrayHasKey('lastId', $response); + } + + public function testGetViewOpensStatisticsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/view-opens'); + $this->assertHttpForbidden(); + } + + public function testGetViewOpensStatisticsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/view-opens'); + $this->assertHttpOkay(); + } + + public function testGetViewOpensStatisticsReturnsViewData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/view-opens'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('campaigns', $response); + self::assertArrayHasKey('total', $response); + self::assertArrayHasKey('hasMore', $response); + self::assertArrayHasKey('lastId', $response); + } + + public function testGetTopDomainsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/domains/top'); + $this->assertHttpForbidden(); + } + + public function testGetTopDomainsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top'); + $this->assertHttpOkay(); + } + + public function testGetTopDomainsReturnsDomainsData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertArrayHasKey('total', $response); + } + + public function testGetDomainConfirmationStatisticsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/domains/confirmation'); + $this->assertHttpForbidden(); + } + + public function testGetDomainConfirmationStatisticsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/confirmation'); + $this->assertHttpOkay(); + } + + public function testGetDomainConfirmationStatisticsReturnsConfirmationData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/confirmation'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertArrayHasKey('total', $response); + } + + public function testGetTopLocalPartsWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/analytics/local-parts/top'); + $this->assertHttpForbidden(); + } + + public function testGetTopLocalPartsWithValidSessionReturnsOkay(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top'); + $this->assertHttpOkay(); + } + + public function testGetTopLocalPartsReturnsLocalPartsData(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('localParts', $response); + self::assertArrayHasKey('total', $response); + } +} diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php new file mode 100644 index 0000000..05cb857 --- /dev/null +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -0,0 +1,400 @@ +authentication = $this->createMock(Authentication::class); + $this->validator = $this->createMock(RequestValidator::class); + $this->analyticsService = $this->createMock(AnalyticsService::class); + $this->controller = new TestableAnalyticsController( + $this->authentication, + $this->validator, + $this->analyticsService + ); + + $this->privileges = $this->createMock(Privileges::class); + $this->administrator = $this->createMock(Administrator::class); + $this->administrator->method('getPrivileges')->willReturn($this->privileges); + } + + public function testGetCampaignStatisticsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getCampaignStatistics($request); + } + + public function testGetCampaignStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + $request->query->set('last_id', '10'); + + $expectedData = [ + 'campaigns' => [ + [ + 'campaignId' => 1, + 'subject' => 'Test Campaign', + 'dateSent' => '2023-01-01T00:00:00+00:00', + 'sent' => 100, + 'bounces' => 5, + 'forwards' => 2, + 'uniqueViews' => 80, + 'totalClicks' => 150, + 'uniqueClicks' => 70, + ] + ], + 'total' => 1, + 'hasMore' => false, + 'lastId' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getCampaignStatistics') + ->with(20, 10) + ->willReturn($expectedData); + + $response = $this->controller->getCampaignStatistics($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } + + public function testGetViewOpensStatisticsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getViewOpensStatistics($request); + } + + public function testGetViewOpensStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + $request->query->set('last_id', '10'); + + $expectedData = [ + 'campaigns' => [ + [ + 'campaignId' => 1, + 'subject' => 'Test Campaign', + 'sent' => 100, + 'uniqueViews' => 80, + 'rate' => 80.0, + ] + ], + 'total' => 1, + 'hasMore' => false, + 'lastId' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getViewOpensStatistics') + ->with(20, 10) + ->willReturn($expectedData); + + $response = $this->controller->getViewOpensStatistics($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } + + public function testGetTopDomainsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getTopDomains($request); + } + + public function testGetTopDomainsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + $request->query->set('min_subscribers', '10'); + + $expectedData = [ + 'domains' => [ + [ + 'domain' => 'example.com', + 'subscribers' => 50, + ] + ], + 'total' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getTopDomains') + ->with(20, 10) + ->willReturn($expectedData); + + $response = $this->controller->getTopDomains($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } + + public function testGetDomainConfirmationStatisticsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getDomainConfirmationStatistics($request); + } + + public function testGetDomainConfirmationStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + + $expectedData = [ + 'domains' => [ + [ + 'domain' => 'example.com', + 'confirmed' => [ + 'count' => 40, + 'percentage' => 80.0, + ], + 'unconfirmed' => [ + 'count' => 5, + 'percentage' => 10.0, + ], + 'blacklisted' => [ + 'count' => 5, + 'percentage' => 10.0, + ], + 'total' => [ + 'count' => 50, + 'percentage' => 100.0, + ], + ] + ], + 'total' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getDomainConfirmationStatistics') + ->with(20) + ->willReturn($expectedData); + + $response = $this->controller->getDomainConfirmationStatistics($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } + + public function testGetTopLocalPartsWithoutStatisticsPrivilegeThrowsException(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(false); + + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('You are not allowed to access statistics.'); + + $this->controller->getTopLocalParts($request); + } + + public function testGetTopLocalPartsReturnsJsonResponse(): void + { + $request = new Request(); + $request->query->set('limit', '20'); + + $expectedData = [ + 'localParts' => [ + [ + 'localPart' => 'info', + 'count' => 30, + 'percentage' => 60.0, + ] + ], + 'total' => 1, + ]; + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::once()) + ->method('has') + ->with(PrivilegeFlag::Statistics) + ->willReturn(true); + + $this->analyticsService + ->expects(self::once()) + ->method('getTopLocalParts') + ->with(20) + ->willReturn($expectedData); + + $response = $this->controller->getTopLocalParts($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } +} From 56e550e7f8d8ba64a1e347187e558220eb08645c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 16 Jun 2025 11:30:02 +0400 Subject: [PATCH 06/20] ISSUE-345: campaign stat normalizer --- config/services/normalizers.yml | 4 ++ .../Controller/AnalyticsController.php | 37 +++++------ .../OpenApi/SwaggerSchemasRequest.php | 9 +++ .../OpenApi/SwaggerSchemasResponse.php | 27 ++++++++ .../CampaignStatisticsNormalizer.php | 63 +++++++++++++++++++ .../Controller/AnalyticsControllerTest.php | 37 +++++++++-- 6 files changed, 150 insertions(+), 27 deletions(-) create mode 100644 src/Statistics/OpenApi/SwaggerSchemasRequest.php create mode 100644 src/Statistics/OpenApi/SwaggerSchemasResponse.php create mode 100644 src/Statistics/Serializer/CampaignStatisticsNormalizer.php diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 0420706..736fb3a 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -69,3 +69,7 @@ services: PhpList\RestBundle\Subscription\Serializer\SubscribersExportRequestNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index 52ce260..78619aa 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -10,6 +10,7 @@ use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; +use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -21,15 +22,19 @@ #[Route('/analytics', name: 'analytics_')] class AnalyticsController extends BaseController { + public const BATCH_SIZE = 20; private AnalyticsService $analyticsService; + private CampaignStatisticsNormalizer $campaignStatisticsNormalizer; public function __construct( Authentication $authentication, RequestValidator $validator, - AnalyticsService $analyticsService + AnalyticsService $analyticsService, + CampaignStatisticsNormalizer $campaignStatisticsNormalizer ) { parent::__construct($authentication, $validator); $this->analyticsService = $analyticsService; + $this->campaignStatisticsNormalizer = $campaignStatisticsNormalizer; } #[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])] @@ -56,7 +61,7 @@ public function __construct( schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) ), new OA\Parameter( - name: 'last_id', + name: 'after_id', description: 'Last seen campaign ID for pagination', in: 'query', required: false, @@ -70,26 +75,11 @@ public function __construct( content: new OA\JsonContent( properties: [ new OA\Property( - property: 'campaigns', + property: 'items', type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'campaignId', type: 'integer'), - new OA\Property(property: 'subject', type: 'string'), - new OA\Property(property: 'dateSent', type: 'string', format: 'date-time'), - new OA\Property(property: 'sent', type: 'integer'), - new OA\Property(property: 'bounces', type: 'integer'), - new OA\Property(property: 'forwards', type: 'integer'), - new OA\Property(property: 'uniqueViews', type: 'integer'), - new OA\Property(property: 'totalClicks', type: 'integer'), - new OA\Property(property: 'uniqueClicks', type: 'integer'), - ], - type: 'object' - ) + items: new OA\Items(ref: '#/components/schemas/CampaignStatistics') ), - new OA\Property(property: 'total', type: 'integer'), - new OA\Property(property: 'hasMore', type: 'boolean'), - new OA\Property(property: 'lastId', type: 'integer'), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') ], type: 'object' ) @@ -108,12 +98,13 @@ public function getCampaignStatistics(Request $request): JsonResponse throw $this->createAccessDeniedException('You are not allowed to access statistics.'); } - $limit = (int) $request->query->get('limit', 50); - $lastId = (int) $request->query->get('last_id', 0); + $limit = (int) $request->query->get('limit', self::BATCH_SIZE); + $lastId = (int) $request->query->get('after_id', 0); $data = $this->analyticsService->getCampaignStatistics($limit, $lastId); + $normalizedData = $this->campaignStatisticsNormalizer->normalize($data, null, ['limit' => $limit]); - return $this->json($data, Response::HTTP_OK); + return $this->json($normalizedData, Response::HTTP_OK); } #[Route('/view-opens', name: 'view_opens_statistics', methods: ['GET'])] diff --git a/src/Statistics/OpenApi/SwaggerSchemasRequest.php b/src/Statistics/OpenApi/SwaggerSchemasRequest.php new file mode 100644 index 0000000..1dcb53a --- /dev/null +++ b/src/Statistics/OpenApi/SwaggerSchemasRequest.php @@ -0,0 +1,9 @@ + array_map(function ($campaign) { + return [ + 'campaign_id' => $campaign['campaignId'] ?? 0, + 'subject' => $campaign['subject'] ?? '', + 'dateSent' => $campaign['dateSent'] ?? null, + 'sent' => $campaign['sent'] ?? 0, + 'bounces' => $campaign['bounces'] ?? 0, + 'forwards' => $campaign['forwards'] ?? 0, + 'unique_views' => $campaign['uniqueViews'] ?? 0, + 'total_clicks' => $campaign['totalClicks'] ?? 0, + 'unique_clicks' => $campaign['uniqueClicks'] ?? 0, + ]; + }, $object['campaigns']), + 'pagination' => [ + 'total' => $object['total'] ?? 0, + 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, + 'has_more' => $object['hasMore'] ?? false, + 'next_cursor' => $object['lastId'] ?? 0, + ], + ]; + } + + /** + * Checks whether the given class is supported for normalization by this normalizer. + * + * @param mixed $data Data to normalize + * @param string|null $format The format being (de)serialized from or into + * @param array $context Context options for the normalizer + * + * @return bool + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($data['campaigns']); + } +} diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php index 05cb857..e9fa4f2 100644 --- a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -11,6 +11,7 @@ use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Statistics\Controller\AnalyticsController; +use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\JsonResponse; @@ -35,6 +36,7 @@ class AnalyticsControllerTest extends TestCase private Authentication|MockObject $authentication; private RequestValidator|MockObject $validator; private AnalyticsService|MockObject $analyticsService; + private CampaignStatisticsNormalizer|MockObject $campaignStatisticsNormalizer; private AnalyticsController $controller; private Administrator|MockObject $administrator; private Privileges|MockObject $privileges; @@ -44,10 +46,12 @@ protected function setUp(): void $this->authentication = $this->createMock(Authentication::class); $this->validator = $this->createMock(RequestValidator::class); $this->analyticsService = $this->createMock(AnalyticsService::class); + $this->campaignStatisticsNormalizer = $this->createMock(CampaignStatisticsNormalizer::class); $this->controller = new TestableAnalyticsController( $this->authentication, $this->validator, - $this->analyticsService + $this->analyticsService, + $this->campaignStatisticsNormalizer ); $this->privileges = $this->createMock(Privileges::class); @@ -83,7 +87,26 @@ public function testGetCampaignStatisticsReturnsJsonResponse(): void $request->query->set('limit', '20'); $request->query->set('last_id', '10'); - $expectedData = [ + $serviceData = [ + 'campaigns' => [ + [ + 'campaignId' => 1, + 'subject' => 'Test Campaign', + 'dateSent' => '2023-01-01T00:00:00+00:00', + 'sent' => 100, + 'bounces' => 5, + 'forwards' => 2, + 'uniqueViews' => 80, + 'totalClicks' => 150, + 'uniqueClicks' => 70, + ] + ], + 'total' => 1, + 'hasMore' => false, + 'lastId' => 1, + ]; + + $normalizedData = [ 'campaigns' => [ [ 'campaignId' => 1, @@ -118,13 +141,19 @@ public function testGetCampaignStatisticsReturnsJsonResponse(): void ->expects(self::once()) ->method('getCampaignStatistics') ->with(20, 10) - ->willReturn($expectedData); + ->willReturn($serviceData); + + $this->campaignStatisticsNormalizer + ->expects(self::once()) + ->method('normalize') + ->with($serviceData) + ->willReturn($normalizedData); $response = $this->controller->getCampaignStatistics($request); self::assertInstanceOf(JsonResponse::class, $response); self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); - self::assertEquals($expectedData, json_decode($response->getContent(), true)); + self::assertEquals($normalizedData, json_decode($response->getContent(), true)); } public function testGetViewOpensStatisticsWithoutStatisticsPrivilegeThrowsException(): void From 37869749ab7c77699911eca503c30c4f1ee2bdf1 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 16 Jun 2025 11:41:24 +0400 Subject: [PATCH 07/20] ISSUE-345: view open stat normalizer --- config/services/normalizers.yml | 4 ++ .../Controller/AnalyticsController.php | 43 +++++++------ .../OpenApi/SwaggerSchemasResponse.php | 12 ++++ .../CampaignStatisticsNormalizer.php | 4 +- .../ViewOpensStatisticsNormalizer.php | 59 ++++++++++++++++++ .../Controller/AnalyticsControllerTest.php | 6 +- .../Controller/AnalyticsControllerTest.php | 60 ++++++++++++------- 7 files changed, 137 insertions(+), 51 deletions(-) create mode 100644 src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 736fb3a..2ac511c 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -73,3 +73,7 @@ services: PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index 78619aa..db41904 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -11,6 +11,7 @@ use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; +use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,16 +26,19 @@ class AnalyticsController extends BaseController public const BATCH_SIZE = 20; private AnalyticsService $analyticsService; private CampaignStatisticsNormalizer $campaignStatisticsNormalizer; + private ViewOpensStatisticsNormalizer $viewOpensStatisticsNormalizer; public function __construct( Authentication $authentication, RequestValidator $validator, AnalyticsService $analyticsService, - CampaignStatisticsNormalizer $campaignStatisticsNormalizer + CampaignStatisticsNormalizer $campaignStatisticsNormalizer, + ViewOpensStatisticsNormalizer $viewOpensStatisticsNormalizer ) { parent::__construct($authentication, $validator); $this->analyticsService = $analyticsService; $this->campaignStatisticsNormalizer = $campaignStatisticsNormalizer; + $this->viewOpensStatisticsNormalizer = $viewOpensStatisticsNormalizer; } #[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])] @@ -58,7 +62,7 @@ public function __construct( description: 'Maximum number of campaigns to return', in: 'query', required: false, - schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + schema: new OA\Schema(type: 'integer', default: 20, minimum: 1) ), new OA\Parameter( name: 'after_id', @@ -128,10 +132,10 @@ public function getCampaignStatistics(Request $request): JsonResponse description: 'Maximum number of campaigns to return', in: 'query', required: false, - schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + schema: new OA\Schema(type: 'integer', default: 20, minimum: 1) ), new OA\Parameter( - name: 'last_id', + name: 'after_id', description: 'Last seen campaign ID for pagination', in: 'query', required: false, @@ -145,22 +149,11 @@ public function getCampaignStatistics(Request $request): JsonResponse content: new OA\JsonContent( properties: [ new OA\Property( - property: 'campaigns', + property: 'items', type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'campaignId', type: 'integer'), - new OA\Property(property: 'subject', type: 'string'), - new OA\Property(property: 'sent', type: 'integer'), - new OA\Property(property: 'uniqueViews', type: 'integer'), - new OA\Property(property: 'rate', type: 'number', format: 'float'), - ], - type: 'object' - ) + items: new OA\Items(ref: '#/components/schemas/ViewOpensStatistics') ), - new OA\Property(property: 'total', type: 'integer'), - new OA\Property(property: 'hasMore', type: 'boolean'), - new OA\Property(property: 'lastId', type: 'integer'), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') ], type: 'object' ) @@ -179,12 +172,16 @@ public function getViewOpensStatistics(Request $request): JsonResponse throw $this->createAccessDeniedException('You are not allowed to access statistics.'); } - $limit = (int) $request->query->get('limit', 50); - $lastId = (int) $request->query->get('last_id', 0); + $limit = (int) $request->query->get('limit', self::BATCH_SIZE); + $lastId = (int) $request->query->get('after_id', 0); $data = $this->analyticsService->getViewOpensStatistics($limit, $lastId); + $normalizedData = $this->viewOpensStatisticsNormalizer->normalize($data, null, [ + 'view_opens_statistics' => true, + 'limit' => $limit + ]); - return $this->json($data, Response::HTTP_OK); + return $this->json($normalizedData, Response::HTTP_OK); } #[Route('/domains/top', name: 'top_domains', methods: ['GET'])] @@ -208,7 +205,7 @@ public function getViewOpensStatistics(Request $request): JsonResponse description: 'Maximum number of domains to return', in: 'query', required: false, - schema: new OA\Schema(type: 'integer', default: 50, maximum: 100, minimum: 1) + schema: new OA\Schema(type: 'integer', default: 20, minimum: 1) ), new OA\Parameter( name: 'min_subscribers', @@ -254,7 +251,7 @@ public function getTopDomains(Request $request): JsonResponse throw $this->createAccessDeniedException('You are not allowed to access statistics.'); } - $limit = (int) $request->query->get('limit', 50); + $limit = (int) $request->query->get('limit', self::BATCH_SIZE); $minSubscribers = (int) $request->query->get('min_subscribers', 5); $data = $this->analyticsService->getTopDomains($limit, $minSubscribers); diff --git a/src/Statistics/OpenApi/SwaggerSchemasResponse.php b/src/Statistics/OpenApi/SwaggerSchemasResponse.php index e353b30..f4d0c9b 100644 --- a/src/Statistics/OpenApi/SwaggerSchemasResponse.php +++ b/src/Statistics/OpenApi/SwaggerSchemasResponse.php @@ -22,6 +22,18 @@ type: 'object', nullable: true )] +#[OA\Schema( + schema: 'ViewOpensStatistics', + properties: [ + new OA\Property(property: 'campaign_id', type: 'integer'), + new OA\Property(property: 'subject', type: 'string'), + new OA\Property(property: 'sent', type: 'integer'), + new OA\Property(property: 'unique_views', type: 'integer'), + new OA\Property(property: 'rate', type: 'number', format: 'float'), + ], + type: 'object', + nullable: true +)] class SwaggerSchemasResponse { } diff --git a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php index 2a62fb1..e689846 100644 --- a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php +++ b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php @@ -29,20 +29,20 @@ public function normalize(mixed $object, string $format = null, array $context = return [ 'campaign_id' => $campaign['campaignId'] ?? 0, 'subject' => $campaign['subject'] ?? '', - 'dateSent' => $campaign['dateSent'] ?? null, 'sent' => $campaign['sent'] ?? 0, 'bounces' => $campaign['bounces'] ?? 0, 'forwards' => $campaign['forwards'] ?? 0, 'unique_views' => $campaign['uniqueViews'] ?? 0, 'total_clicks' => $campaign['totalClicks'] ?? 0, 'unique_clicks' => $campaign['uniqueClicks'] ?? 0, + 'date_sent' => $campaign['dateSent'] ?? null, ]; }, $object['campaigns']), 'pagination' => [ 'total' => $object['total'] ?? 0, 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, 'has_more' => $object['hasMore'] ?? false, - 'next_cursor' => $object['lastId'] ?? 0, + 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, ], ]; } diff --git a/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php new file mode 100644 index 0000000..c765f78 --- /dev/null +++ b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php @@ -0,0 +1,59 @@ + array_map(function ($item) { + return [ + 'campaign_id' => $item['campaignId'] ?? 0, + 'subject' => $item['subject'] ?? '', + 'sent' => $item['sent'] ?? 0, + 'unique_views' => $item['uniqueViews'] ?? 0, + 'rate' => $item['rate'] ?? 0.0, + ]; + }, $object['campaigns']), + 'pagination' => [ + 'total' => $object['total'] ?? 0, + 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, + 'has_more' => $object['hasMore'] ?? false, + 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, + ], + ]; + } + + /** + * Checks whether the given class is supported for normalization by this normalizer. + * + * @param mixed $data Data to normalize + * @param string|null $format The format being (de)serialized from or into + * @param array $context Context options for the normalizer + * + * @return bool + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($data['items']) && isset($context['view_opens_statistics']); + } +} diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php index 0ae4b8f..abf8475 100644 --- a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -55,10 +55,8 @@ public function testGetCampaignStatisticsReturnsCampaignData(): void $response = $this->getDecodedJsonResponseContent(); self::assertIsArray($response); - self::assertArrayHasKey('campaigns', $response); - self::assertArrayHasKey('total', $response); - self::assertArrayHasKey('hasMore', $response); - self::assertArrayHasKey('lastId', $response); + self::assertArrayHasKey('items', $response); + self::assertArrayHasKey('pagination', $response); } public function testGetViewOpensStatisticsWithoutSessionKeyReturnsForbidden(): void diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php index e9fa4f2..e912da1 100644 --- a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -12,6 +12,7 @@ use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Statistics\Controller\AnalyticsController; use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; +use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\JsonResponse; @@ -46,12 +47,14 @@ protected function setUp(): void $this->authentication = $this->createMock(Authentication::class); $this->validator = $this->createMock(RequestValidator::class); $this->analyticsService = $this->createMock(AnalyticsService::class); - $this->campaignStatisticsNormalizer = $this->createMock(CampaignStatisticsNormalizer::class); + $this->campaignStatisticsNormalizer = new CampaignStatisticsNormalizer(); + $this->viewOpensStatisticsNormalizer = new ViewOpensStatisticsNormalizer(); $this->controller = new TestableAnalyticsController( $this->authentication, $this->validator, $this->analyticsService, - $this->campaignStatisticsNormalizer + $this->campaignStatisticsNormalizer, + $this->viewOpensStatisticsNormalizer, ); $this->privileges = $this->createMock(Privileges::class); @@ -85,7 +88,7 @@ public function testGetCampaignStatisticsReturnsJsonResponse(): void { $request = new Request(); $request->query->set('limit', '20'); - $request->query->set('last_id', '10'); + $request->query->set('after_id', '10'); $serviceData = [ 'campaigns' => [ @@ -107,22 +110,25 @@ public function testGetCampaignStatisticsReturnsJsonResponse(): void ]; $normalizedData = [ - 'campaigns' => [ + 'items' => [ [ - 'campaignId' => 1, + 'campaign_id' => 1, 'subject' => 'Test Campaign', - 'dateSent' => '2023-01-01T00:00:00+00:00', + 'date_sent' => '2023-01-01T00:00:00+00:00', 'sent' => 100, 'bounces' => 5, 'forwards' => 2, - 'uniqueViews' => 80, - 'totalClicks' => 150, - 'uniqueClicks' => 70, + 'unique_views' => 80, + 'total_clicks' => 150, + 'unique_clicks' => 70, ] ], - 'total' => 1, - 'hasMore' => false, - 'lastId' => 1, + 'pagination' => [ + 'total' => 1, + 'limit' => 20, + 'has_more' => false, + 'next_cursor' => 2, + ], ]; $this->authentication @@ -143,15 +149,8 @@ public function testGetCampaignStatisticsReturnsJsonResponse(): void ->with(20, 10) ->willReturn($serviceData); - $this->campaignStatisticsNormalizer - ->expects(self::once()) - ->method('normalize') - ->with($serviceData) - ->willReturn($normalizedData); - $response = $this->controller->getCampaignStatistics($request); - self::assertInstanceOf(JsonResponse::class, $response); self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); self::assertEquals($normalizedData, json_decode($response->getContent(), true)); } @@ -182,7 +181,7 @@ public function testGetViewOpensStatisticsReturnsJsonResponse(): void { $request = new Request(); $request->query->set('limit', '20'); - $request->query->set('last_id', '10'); + $request->query->set('after_id', '10'); $expectedData = [ 'campaigns' => [ @@ -199,6 +198,24 @@ public function testGetViewOpensStatisticsReturnsJsonResponse(): void 'lastId' => 1, ]; + $normalizedData = [ + 'items' => [ + [ + 'campaign_id' => 1, + 'subject' => 'Test Campaign', + 'sent' => 100, + 'unique_views' => 80, + 'rate' => 80, + ] + ], + 'pagination' => [ + 'total' => 1, + 'limit' => 20, + 'has_more' => false, + 'next_cursor' => 2, + ], + ]; + $this->authentication ->expects(self::once()) ->method('authenticateByApiKey') @@ -219,9 +236,8 @@ public function testGetViewOpensStatisticsReturnsJsonResponse(): void $response = $this->controller->getViewOpensStatistics($request); - self::assertInstanceOf(JsonResponse::class, $response); self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); - self::assertEquals($expectedData, json_decode($response->getContent(), true)); + self::assertEquals($normalizedData, json_decode($response->getContent(), true)); } public function testGetTopDomainsWithoutStatisticsPrivilegeThrowsException(): void From 072c18ea87981a1655b818daba0fd00de68cb514 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 17 Jun 2025 18:55:34 +0400 Subject: [PATCH 08/20] ISSUE-345: return 400 on validation exception --- src/Common/EventListener/ExceptionListener.php | 6 ++++++ .../Controller/AdminAttributeDefinitionControllerTest.php | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index f48bf86..7e46f7b 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -10,6 +10,7 @@ use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\Validator\Exception\ValidatorException; class ExceptionListener { @@ -34,6 +35,11 @@ public function onKernelException(ExceptionEvent $event): void 'message' => $exception->getMessage(), ], $exception->getStatusCode()); $event->setResponse($response); + } elseif ($exception instanceof ValidatorException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], 400); + $event->setResponse($response); } elseif ($exception instanceof Exception) { $response = new JsonResponse([ 'message' => $exception->getMessage(), diff --git a/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php b/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php index dfa6618..dc3245d 100644 --- a/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php +++ b/tests/Integration/Identity/Controller/AdminAttributeDefinitionControllerTest.php @@ -31,7 +31,7 @@ public function testCreateAttributeDefinitionWithValidDataReturnsCreated(): void { $this->authenticatedJsonRequest('post', '/api/v2/administrators/attributes', [], [], [], json_encode([ 'name' => 'Test Attribute', - 'type' => 'text', + 'type' => 'textarea', 'order' => 1, 'defaultValue' => 'default', 'required' => true, @@ -41,7 +41,7 @@ public function testCreateAttributeDefinitionWithValidDataReturnsCreated(): void $this->assertHttpCreated(); $data = $this->getDecodedJsonResponseContent(); self::assertSame('Test Attribute', $data['name']); - self::assertSame('text', $data['type']); + self::assertSame('textarea', $data['type']); self::assertSame(1, $data['list_order']); self::assertSame('default', $data['default_value']); self::assertTrue($data['required']); From 581e409964fcf04f6f87495615e53b2035384e59 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 17 Jun 2025 19:18:07 +0400 Subject: [PATCH 09/20] ISSUE-345: test fix --- src/Common/EventListener/ExceptionListener.php | 6 ++++++ .../Statistics/Controller/AnalyticsControllerTest.php | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index 7e46f7b..d39b8f3 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -5,6 +5,7 @@ namespace PhpList\RestBundle\Common\EventListener; use Exception; +use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; @@ -35,6 +36,11 @@ public function onKernelException(ExceptionEvent $event): void 'message' => $exception->getMessage(), ], $exception->getStatusCode()); $event->setResponse($response); + } elseif ($exception instanceof AdminAttributeCreationException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], $exception->getStatusCode()); + $event->setResponse($response); } elseif ($exception instanceof ValidatorException) { $response = new JsonResponse([ 'message' => $exception->getMessage(), diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php index abf8475..21e86bc 100644 --- a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -81,10 +81,10 @@ public function testGetViewOpensStatisticsReturnsViewData(): void $response = $this->getDecodedJsonResponseContent(); self::assertIsArray($response); - self::assertArrayHasKey('campaigns', $response); - self::assertArrayHasKey('total', $response); - self::assertArrayHasKey('hasMore', $response); - self::assertArrayHasKey('lastId', $response); + self::assertArrayHasKey('items', $response); + self::assertArrayHasKey('pagination', $response); + self::assertIsArray($response['items']); + self::assertIsArray($response['pagination']); } public function testGetTopDomainsWithoutSessionKeyReturnsForbidden(): void From 24c5f75503ca25fca1f95549f47ef24ceca7db71 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 17 Jun 2025 20:13:06 +0400 Subject: [PATCH 10/20] ISSUE-345: make it more specific --- src/Statistics/Controller/AnalyticsController.php | 5 ++++- src/Statistics/Serializer/CampaignStatisticsNormalizer.php | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index db41904..b52f233 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -106,7 +106,10 @@ public function getCampaignStatistics(Request $request): JsonResponse $lastId = (int) $request->query->get('after_id', 0); $data = $this->analyticsService->getCampaignStatistics($limit, $lastId); - $normalizedData = $this->campaignStatisticsNormalizer->normalize($data, null, ['limit' => $limit]); + $normalizedData = $this->campaignStatisticsNormalizer->normalize($data, null, [ + 'limit' => $limit, + 'campaign_statistics' => true, + ]); return $this->json($normalizedData, Response::HTTP_OK); } diff --git a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php index e689846..06e012a 100644 --- a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php +++ b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php @@ -58,6 +58,6 @@ public function normalize(mixed $object, string $format = null, array $context = */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { - return is_array($data) && isset($data['campaigns']); + return is_array($data) && isset($data['campaign_statistics']); } } From cceadfc47e0de313a7a374c96b908cd5c9179322 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 17 Jun 2025 20:36:29 +0400 Subject: [PATCH 11/20] ISSUE-345: style fix --- config/services/providers.yml | 4 + src/Common/Controller/BaseController.php | 1 + .../Controller/CampaignController.php | 62 +--- src/Messaging/Service/CampaignService.php | 90 ++++++ .../Controller/AnalyticsController.php | 31 +- .../CampaignStatisticsNormalizer.php | 66 ++-- .../ViewOpensStatisticsNormalizer.php | 59 ++-- tests/Helpers/DummyAnalyticsController.php | 16 + .../Messaging/Service/CampaignServiceTest.php | 296 ++++++++++++++++++ .../Controller/AnalyticsControllerTest.php | 29 +- 10 files changed, 513 insertions(+), 141 deletions(-) create mode 100644 src/Messaging/Service/CampaignService.php create mode 100644 tests/Helpers/DummyAnalyticsController.php create mode 100644 tests/Unit/Messaging/Service/CampaignServiceTest.php diff --git a/config/services/providers.yml b/config/services/providers.yml index be69707..4f276e2 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -7,3 +7,7 @@ services: PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider: autowire: true autoconfigure: true + + PhpList\RestBundle\Messaging\Service\CampaignService: + autowire: true + autoconfigure: true diff --git a/src/Common/Controller/BaseController.php b/src/Common/Controller/BaseController.php index 32bed0a..136216b 100644 --- a/src/Common/Controller/BaseController.php +++ b/src/Common/Controller/BaseController.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +/** @SuppressWarnings(PHPMD.NumberOfChildren) */ abstract class BaseController extends AbstractController { protected Authentication $authentication; diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index eb2a22f..29c198b 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -5,17 +5,13 @@ namespace PhpList\RestBundle\Messaging\Controller; use OpenApi\Attributes as OA; -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\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; -use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Messaging\Request\CreateMessageRequest; use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest; -use PhpList\RestBundle\Messaging\Serializer\MessageNormalizer; +use PhpList\RestBundle\Messaging\Service\CampaignService; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -30,21 +26,15 @@ #[Route('/campaigns', name: 'campaign_')] class CampaignController extends BaseController { - private MessageNormalizer $normalizer; - private MessageManager $messageManager; - private PaginatedDataProvider $paginatedProvider; + private CampaignService $campaignService; public function __construct( Authentication $authentication, RequestValidator $validator, - MessageNormalizer $normalizer, - MessageManager $messageManager, - PaginatedDataProvider $paginatedProvider, + CampaignService $campaignService, ) { parent::__construct($authentication, $validator); - $this->normalizer = $normalizer; - $this->messageManager = $messageManager; - $this->paginatedProvider = $paginatedProvider; + $this->campaignService = $campaignService; } #[Route('', name: 'get_list', methods: ['GET'])] @@ -104,12 +94,10 @@ public function __construct( )] public function getMessages(Request $request): JsonResponse { - $authUer = $this->requireAuthentication($request); - - $filter = (new MessageFilter())->setOwner($authUer); + $authUser = $this->requireAuthentication($request); return $this->json( - $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter), + $this->campaignService->getMessages($request, $authUser), Response::HTTP_OK ); } @@ -158,11 +146,7 @@ public function getMessage( ): JsonResponse { $this->requireAuthentication($request); - if (!$message) { - throw $this->createNotFoundException('Campaign not found.'); - } - - return $this->json($this->normalizer->normalize($message), Response::HTTP_OK); + return $this->json($this->campaignService->getMessage($message), Response::HTTP_OK); } #[Route('', name: 'create', methods: ['POST'])] @@ -217,18 +201,17 @@ public function getMessage( ), ] )] - public function createMessage(Request $request, MessageNormalizer $normalizer): JsonResponse + public function createMessage(Request $request): JsonResponse { $authUser = $this->requireAuthentication($request); - if (!$authUser->getPrivileges()->has(PrivilegeFlag::Campaigns)) { - throw $this->createAccessDeniedException('You are not allowed to create campaigns.'); - } /** @var CreateMessageRequest $createMessageRequest */ $createMessageRequest = $this->validator->validate($request, CreateMessageRequest::class); - $data = $this->messageManager->createMessage($createMessageRequest->getDto(), $authUser); - return $this->json($normalizer->normalize($data), Response::HTTP_CREATED); + return $this->json( + $this->campaignService->createMessage($createMessageRequest, $authUser), + Response::HTTP_CREATED + ); } #[Route('/{messageId}', name: 'update', requirements: ['messageId' => '\d+'], methods: ['PUT'])] @@ -294,18 +277,14 @@ public function updateMessage( #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, ): JsonResponse { $authUser = $this->requireAuthentication($request); - if (!$authUser->getPrivileges()->has(PrivilegeFlag::Campaigns)) { - throw $this->createAccessDeniedException('You are not allowed to update campaigns.'); - } - if (!$message) { - throw $this->createNotFoundException('Campaign not found.'); - } /** @var UpdateMessageRequest $updateMessageRequest */ $updateMessageRequest = $this->validator->validate($request, UpdateMessageRequest::class); - $data = $this->messageManager->updateMessage($updateMessageRequest->getDto(), $message, $authUser); - return $this->json($this->normalizer->normalize($data), Response::HTTP_OK); + return $this->json( + $this->campaignService->updateMessage($updateMessageRequest, $authUser, $message), + Response::HTTP_OK + ); } #[Route('/{messageId}', name: 'delete', requirements: ['messageId' => '\d+'], methods: ['DELETE'])] @@ -356,15 +335,8 @@ public function deleteMessage( #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null ): JsonResponse { $authUser = $this->requireAuthentication($request); - if (!$authUser->getPrivileges()->has(PrivilegeFlag::Campaigns)) { - throw $this->createAccessDeniedException('You are not allowed to delete campaigns.'); - } - - if (!$message) { - throw $this->createNotFoundException('Campaign not found.'); - } - $this->messageManager->delete($message); + $this->campaignService->deleteMessage($authUser, $message); return $this->json(null, Response::HTTP_NO_CONTENT); } diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php new file mode 100644 index 0000000..b6680d1 --- /dev/null +++ b/src/Messaging/Service/CampaignService.php @@ -0,0 +1,90 @@ +setOwner($administrator); + + return $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter); + } + + public function getMessage(Message $message = null): array + { + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + + return $this->normalizer->normalize($message); + } + + public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $administrator): array + { + if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw new AccessDeniedHttpException('You are not allowed to create campaigns.'); + } + + $data = $this->messageManager->createMessage($createMessageRequest->getDto(), $administrator); + + return $this->normalizer->normalize($data); + } + + public function updateMessage( + UpdateMessageRequest $updateMessageRequest, + Administrator $administrator, + Message $message = null + ): array { + if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw new AccessDeniedHttpException('You are not allowed to update campaigns.'); + } + + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + + $data = $this->messageManager->updateMessage( + $updateMessageRequest->getDto(), + $message, + $administrator + ); + + return $this->normalizer->normalize($data); + } + + public function deleteMessage(Administrator $administrator, Message $message = null): void + { + if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { + throw new AccessDeniedHttpException('You are not allowed to delete campaigns.'); + } + + if (!$message) { + throw new NotFoundHttpException('Campaign not found.'); + } + + $this->messageManager->delete($message); + } +} diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index b52f233..a228c82 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -25,26 +25,27 @@ class AnalyticsController extends BaseController { public const BATCH_SIZE = 20; private AnalyticsService $analyticsService; - private CampaignStatisticsNormalizer $campaignStatisticsNormalizer; - private ViewOpensStatisticsNormalizer $viewOpensStatisticsNormalizer; + private CampaignStatisticsNormalizer $campaignStatsNormalizer; + private ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer; public function __construct( Authentication $authentication, RequestValidator $validator, AnalyticsService $analyticsService, - CampaignStatisticsNormalizer $campaignStatisticsNormalizer, - ViewOpensStatisticsNormalizer $viewOpensStatisticsNormalizer + CampaignStatisticsNormalizer $campaignStatsNormalizer, + ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer ) { parent::__construct($authentication, $validator); $this->analyticsService = $analyticsService; - $this->campaignStatisticsNormalizer = $campaignStatisticsNormalizer; - $this->viewOpensStatisticsNormalizer = $viewOpensStatisticsNormalizer; + $this->campaignStatsNormalizer = $campaignStatsNormalizer; + $this->viewOpensStatsNormalizer = $viewOpensStatsNormalizer; } #[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])] #[OA\Get( path: '/analytics/campaigns', - description: 'Returns statistics overview for campaigns.', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics overview for campaigns.', summary: 'Gets campaign statistics.', tags: ['analytics'], parameters: [ @@ -106,7 +107,7 @@ public function getCampaignStatistics(Request $request): JsonResponse $lastId = (int) $request->query->get('after_id', 0); $data = $this->analyticsService->getCampaignStatistics($limit, $lastId); - $normalizedData = $this->campaignStatisticsNormalizer->normalize($data, null, [ + $normalizedData = $this->campaignStatsNormalizer->normalize($data, null, [ 'limit' => $limit, 'campaign_statistics' => true, ]); @@ -117,7 +118,8 @@ public function getCampaignStatistics(Request $request): JsonResponse #[Route('/view-opens', name: 'view_opens_statistics', methods: ['GET'])] #[OA\Get( path: '/analytics/view-opens', - description: 'Returns statistics for view opens.', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for view opens.', summary: 'Gets view opens statistics.', tags: ['analytics'], parameters: [ @@ -179,7 +181,7 @@ public function getViewOpensStatistics(Request $request): JsonResponse $lastId = (int) $request->query->get('after_id', 0); $data = $this->analyticsService->getViewOpensStatistics($limit, $lastId); - $normalizedData = $this->viewOpensStatisticsNormalizer->normalize($data, null, [ + $normalizedData = $this->viewOpensStatsNormalizer->normalize($data, null, [ 'view_opens_statistics' => true, 'limit' => $limit ]); @@ -190,7 +192,8 @@ public function getViewOpensStatistics(Request $request): JsonResponse #[Route('/domains/top', name: 'top_domains', methods: ['GET'])] #[OA\Get( path: '/analytics/domains/top', - description: 'Returns statistics for the top domains with more than 5 subscribers.', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for the top domains with more than 5 subscribers.', summary: 'Gets top domains statistics.', tags: ['analytics'], parameters: [ @@ -265,7 +268,8 @@ public function getTopDomains(Request $request): JsonResponse #[Route('/domains/confirmation', name: 'domain_confirmation_statistics', methods: ['GET'])] #[OA\Get( path: '/analytics/domains/confirmation', - description: 'Returns statistics for domains showing confirmation status.', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for domains showing confirmation status.', summary: 'Gets domain confirmation statistics.', tags: ['analytics'], parameters: [ @@ -363,7 +367,8 @@ public function getDomainConfirmationStatistics(Request $request): JsonResponse #[Route('/local-parts/top', name: 'top_local_parts', methods: ['GET'])] #[OA\Get( path: '/analytics/local-parts/top', - description: 'Returns statistics for the top local-parts of email addresses.', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns statistics for the top local-parts of email addresses.', summary: 'Gets top local-parts statistics.', tags: ['analytics'], parameters: [ diff --git a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php index 06e012a..3fa276d 100644 --- a/src/Statistics/Serializer/CampaignStatisticsNormalizer.php +++ b/src/Statistics/Serializer/CampaignStatisticsNormalizer.php @@ -10,13 +10,7 @@ class CampaignStatisticsNormalizer implements NormalizerInterface { /** - * Normalizes campaign statistics data into an array. - * - * @param mixed $object The object to normalize - * @param string|null $format The format being (de)serialized from or into - * @param array $context Context options for the normalizer - * - * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function normalize(mixed $object, string $format = null, array $context = []): array { @@ -24,37 +18,43 @@ public function normalize(mixed $object, string $format = null, array $context = return []; } + $items = []; + foreach ($object['campaigns'] as $item) { + $items[] = $this->normalizeCampaign($item); + } + return [ + 'items' => $items, + 'pagination' => $this->normalizePagination($object, $context), + ]; + } + + private function normalizeCampaign(array $campaign): array + { + return [ + 'campaign_id' => $campaign['campaignId'] ?? 0, + 'subject' => $campaign['subject'] ?? '', + 'sent' => $campaign['sent'] ?? 0, + 'bounces' => $campaign['bounces'] ?? 0, + 'forwards' => $campaign['forwards'] ?? 0, + 'unique_views' => $campaign['uniqueViews'] ?? 0, + 'total_clicks' => $campaign['totalClicks'] ?? 0, + 'unique_clicks' => $campaign['uniqueClicks'] ?? 0, + 'date_sent' => $campaign['dateSent'] ?? null, + ]; + } + + private function normalizePagination(array $object, array $context): array + { return [ - 'items' => array_map(function ($campaign) { - return [ - 'campaign_id' => $campaign['campaignId'] ?? 0, - 'subject' => $campaign['subject'] ?? '', - 'sent' => $campaign['sent'] ?? 0, - 'bounces' => $campaign['bounces'] ?? 0, - 'forwards' => $campaign['forwards'] ?? 0, - 'unique_views' => $campaign['uniqueViews'] ?? 0, - 'total_clicks' => $campaign['totalClicks'] ?? 0, - 'unique_clicks' => $campaign['uniqueClicks'] ?? 0, - 'date_sent' => $campaign['dateSent'] ?? null, - ]; - }, $object['campaigns']), - 'pagination' => [ - 'total' => $object['total'] ?? 0, - 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, - 'has_more' => $object['hasMore'] ?? false, - 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, - ], + 'total' => $object['total'] ?? 0, + 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, + 'has_more' => $object['hasMore'] ?? false, + 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, ]; } /** - * Checks whether the given class is supported for normalization by this normalizer. - * - * @param mixed $data Data to normalize - * @param string|null $format The format being (de)serialized from or into - * @param array $context Context options for the normalizer - * - * @return bool + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { diff --git a/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php index c765f78..85cf958 100644 --- a/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php +++ b/src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php @@ -10,13 +10,7 @@ class ViewOpensStatisticsNormalizer implements NormalizerInterface { /** - * Normalizes view opens statistics data into an array. - * - * @param mixed $object The object to normalize - * @param string|null $format The format being (de)serialized from or into - * @param array $context Context options for the normalizer - * - * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function normalize(mixed $object, string $format = null, array $context = []): array { @@ -24,33 +18,40 @@ public function normalize(mixed $object, string $format = null, array $context = return []; } + $items = []; + foreach ($object['campaigns'] as $item) { + $items[] = $this->normalizeCampaign($item); + } + + return [ + 'items' => $items, + 'pagination' => $this->normalizePagination($object, $context), + ]; + } + + private function normalizeCampaign(array $item): array + { + return [ + 'campaign_id' => $item['campaignId'] ?? 0, + 'subject' => $item['subject'] ?? '', + 'sent' => $item['sent'] ?? 0, + 'unique_views' => $item['uniqueViews'] ?? 0, + 'rate' => $item['rate'] ?? 0.0, + ]; + } + + private function normalizePagination(array $object, array $context): array + { return [ - 'items' => array_map(function ($item) { - return [ - 'campaign_id' => $item['campaignId'] ?? 0, - 'subject' => $item['subject'] ?? '', - 'sent' => $item['sent'] ?? 0, - 'unique_views' => $item['uniqueViews'] ?? 0, - 'rate' => $item['rate'] ?? 0.0, - ]; - }, $object['campaigns']), - 'pagination' => [ - 'total' => $object['total'] ?? 0, - 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, - 'has_more' => $object['hasMore'] ?? false, - 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, - ], + 'total' => $object['total'] ?? 0, + 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, + 'has_more' => $object['hasMore'] ?? false, + 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, ]; } /** - * Checks whether the given class is supported for normalization by this normalizer. - * - * @param mixed $data Data to normalize - * @param string|null $format The format being (de)serialized from or into - * @param array $context Context options for the normalizer - * - * @return bool + * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { diff --git a/tests/Helpers/DummyAnalyticsController.php b/tests/Helpers/DummyAnalyticsController.php new file mode 100644 index 0000000..fc3d46e --- /dev/null +++ b/tests/Helpers/DummyAnalyticsController.php @@ -0,0 +1,16 @@ +messageManager = $this->createMock(MessageManager::class); + $this->paginatedProvider = $this->createMock(PaginatedDataProvider::class); + $this->normalizer = $this->createMock(MessageNormalizer::class); + + $this->campaignService = new CampaignService( + $this->messageManager, + $this->paginatedProvider, + $this->normalizer + ); + } + + public function testGetMessagesReturnsExpectedResult(): void + { + $request = new Request(); + $administrator = $this->createMock(Administrator::class); + $expectedResult = ['items' => [], 'pagination' => []]; + + $this->paginatedProvider->expects($this->once()) + ->method('getPaginatedList') + ->with( + $this->identicalTo($request), + $this->identicalTo($this->normalizer), + Message::class, + $this->callback(function (MessageFilter $filter) use ($administrator) { + return $filter->getOwner() === $administrator; + }) + ) + ->willReturn($expectedResult); + + $result = $this->campaignService->getMessages($request, $administrator); + + $this->assertSame($expectedResult, $result); + } + + public function testGetMessageThrowsExceptionWhenMessageIsNull(): void + { + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Campaign not found.'); + + $this->campaignService->getMessage(null); + } + + public function testGetMessageReturnsNormalizedMessage(): void + { + $message = $this->createMock(Message::class); + $expectedResult = ['id' => 1, 'subject' => 'Test Campaign']; + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($this->identicalTo($message)) + ->willReturn($expectedResult); + + $result = $this->campaignService->getMessage($message); + + $this->assertSame($expectedResult, $result); + } + + public function testCreateMessageThrowsExceptionWhenAdministratorLacksPrivileges(): void + { + $createMessageRequest = $this->createMock(CreateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(false); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to create campaigns.'); + + $this->campaignService->createMessage($createMessageRequest, $administrator); + } + + public function testCreateMessageReturnsNormalizedMessage(): void + { + $messageDto = $this->createMock(CreateMessageDto::class); + $createMessageRequest = $this->createMock(CreateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + $expectedResult = ['id' => 1, 'subject' => 'Test Campaign']; + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $createMessageRequest->expects($this->once()) + ->method('getDto') + ->willReturn($messageDto); + + $this->messageManager->expects($this->once()) + ->method('createMessage') + ->with($this->identicalTo($messageDto), $this->identicalTo($administrator)) + ->willReturn($message); + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($this->identicalTo($message)) + ->willReturn($expectedResult); + + $result = $this->campaignService->createMessage($createMessageRequest, $administrator); + + $this->assertSame($expectedResult, $result); + } + + public function testUpdateMessageThrowsExceptionWhenAdministratorLacksPrivileges(): void + { + $updateMessageRequest = $this->createMock(UpdateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(false); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to update campaigns.'); + + $this->campaignService->updateMessage($updateMessageRequest, $administrator, $message); + } + + public function testUpdateMessageThrowsExceptionWhenMessageIsNull(): void + { + $updateMessageRequest = $this->createMock(UpdateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Campaign not found.'); + + $this->campaignService->updateMessage($updateMessageRequest, $administrator, null); + } + + public function testUpdateMessageReturnsNormalizedMessage(): void + { + $messageDto = $this->createMock(UpdateMessageDto::class); + $updateMessageRequest = $this->createMock(UpdateMessageRequest::class); + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + $updatedMessage = $this->createMock(Message::class); + $expectedResult = ['id' => 1, 'subject' => 'Updated Campaign']; + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $updateMessageRequest->expects($this->once()) + ->method('getDto') + ->willReturn($messageDto); + + $this->messageManager->expects($this->once()) + ->method('updateMessage') + ->with( + $this->identicalTo($messageDto), + $this->identicalTo($message), + $this->identicalTo($administrator) + ) + ->willReturn($updatedMessage); + + $this->normalizer->expects($this->once()) + ->method('normalize') + ->with($this->identicalTo($updatedMessage)) + ->willReturn($expectedResult); + + $result = $this->campaignService->updateMessage($updateMessageRequest, $administrator, $message); + + $this->assertSame($expectedResult, $result); + } + + public function testDeleteMessageThrowsExceptionWhenAdministratorLacksPrivileges(): void + { + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(false); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to delete campaigns.'); + + $this->campaignService->deleteMessage($administrator, $message); + } + + public function testDeleteMessageThrowsExceptionWhenMessageIsNull(): void + { + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Campaign not found.'); + + $this->campaignService->deleteMessage($administrator, null); + } + + public function testDeleteMessageCallsMessageManagerDelete(): void + { + $privileges = $this->createMock(Privileges::class); + $administrator = $this->createMock(Administrator::class); + $message = $this->createMock(Message::class); + + $administrator->expects($this->once()) + ->method('getPrivileges') + ->willReturn($privileges); + + $privileges->expects($this->once()) + ->method('has') + ->with(PrivilegeFlag::Campaigns) + ->willReturn(true); + + $this->messageManager->expects($this->once()) + ->method('delete') + ->with($this->identicalTo($message)); + + $this->campaignService->deleteMessage($administrator, $message); + } +} diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php index e912da1..eb072b7 100644 --- a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -13,6 +13,7 @@ use PhpList\RestBundle\Statistics\Controller\AnalyticsController; use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer; +use PhpList\RestBundle\Tests\Helpers\DummyAnalyticsController; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\JsonResponse; @@ -20,24 +21,10 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AccessDeniedException; -/** - * Test-specific subclass of AnalyticsController that overrides the json() method - * to avoid relying on the container. - */ -class TestableAnalyticsController extends AnalyticsController -{ - protected function json($data, int $status = 200, array $headers = [], array $context = []): JsonResponse - { - return new JsonResponse($data, $status, $headers); - } -} - class AnalyticsControllerTest extends TestCase { private Authentication|MockObject $authentication; - private RequestValidator|MockObject $validator; private AnalyticsService|MockObject $analyticsService; - private CampaignStatisticsNormalizer|MockObject $campaignStatisticsNormalizer; private AnalyticsController $controller; private Administrator|MockObject $administrator; private Privileges|MockObject $privileges; @@ -45,16 +32,16 @@ class AnalyticsControllerTest extends TestCase protected function setUp(): void { $this->authentication = $this->createMock(Authentication::class); - $this->validator = $this->createMock(RequestValidator::class); + $validator = $this->createMock(RequestValidator::class); $this->analyticsService = $this->createMock(AnalyticsService::class); - $this->campaignStatisticsNormalizer = new CampaignStatisticsNormalizer(); - $this->viewOpensStatisticsNormalizer = new ViewOpensStatisticsNormalizer(); - $this->controller = new TestableAnalyticsController( + $campaignStatisticsNormalizer = new CampaignStatisticsNormalizer(); + $viewOpensStatisticsNormalizer = new ViewOpensStatisticsNormalizer(); + $this->controller = new DummyAnalyticsController( $this->authentication, - $this->validator, + $validator, $this->analyticsService, - $this->campaignStatisticsNormalizer, - $this->viewOpensStatisticsNormalizer, + $campaignStatisticsNormalizer, + $viewOpensStatisticsNormalizer, ); $this->privileges = $this->createMock(Privileges::class); From e2245aae72a801ee6c8d1b7839dcecddd457214c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 22:08:41 +0400 Subject: [PATCH 12/20] ISSUE-345: refactor --- .../Controller/AnalyticsController.php | 71 ++----------------- .../OpenApi/SwaggerSchemasResponse.php | 69 ++++++++++++++++++ 2 files changed, 73 insertions(+), 67 deletions(-) diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index a228c82..22aebe9 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -225,23 +225,7 @@ public function getViewOpensStatistics(Request $request): JsonResponse new OA\Response( response: 200, description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'domains', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'domain', type: 'string'), - new OA\Property(property: 'subscribers', type: 'integer'), - ], - type: 'object' - ) - ), - new OA\Property(property: 'total', type: 'integer'), - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/TopDomainStats') ), new OA\Response( response: 403, @@ -294,54 +278,7 @@ public function getTopDomains(Request $request): JsonResponse new OA\Response( response: 200, description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'domains', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'domain', type: 'string'), - new OA\Property( - property: 'confirmed', - properties: [ - new OA\Property(property: 'count', type: 'integer'), - new OA\Property(property: 'percentage', type: 'number', format: 'float'), - ], - type: 'object' - ), - new OA\Property( - property: 'unconfirmed', - properties: [ - new OA\Property(property: 'count', type: 'integer'), - new OA\Property(property: 'percentage', type: 'number', format: 'float'), - ], - type: 'object' - ), - new OA\Property( - property: 'blacklisted', - properties: [ - new OA\Property(property: 'count', type: 'integer'), - new OA\Property(property: 'percentage', type: 'number', format: 'float'), - ], - type: 'object' - ), - new OA\Property( - property: 'total', - properties: [ - new OA\Property(property: 'count', type: 'integer'), - new OA\Property(property: 'percentage', type: 'number', format: 'float'), - ], - type: 'object' - ), - ], - type: 'object' - ) - ), - new OA\Property(property: 'total', type: 'integer'), - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/DetailedDomainStats') ), new OA\Response( response: 403, @@ -396,11 +333,11 @@ public function getDomainConfirmationStatistics(Request $request): JsonResponse content: new OA\JsonContent( properties: [ new OA\Property( - property: 'localParts', + property: 'local_parts', type: 'array', items: new OA\Items( properties: [ - new OA\Property(property: 'localPart', type: 'string'), + new OA\Property(property: 'local_part', type: 'string'), new OA\Property(property: 'count', type: 'integer'), new OA\Property(property: 'percentage', type: 'number', format: 'float'), ], diff --git a/src/Statistics/OpenApi/SwaggerSchemasResponse.php b/src/Statistics/OpenApi/SwaggerSchemasResponse.php index f4d0c9b..898f9d9 100644 --- a/src/Statistics/OpenApi/SwaggerSchemasResponse.php +++ b/src/Statistics/OpenApi/SwaggerSchemasResponse.php @@ -34,6 +34,75 @@ type: 'object', nullable: true )] +#[OA\Schema( + schema: 'TopDomainStats', + properties: [ + new OA\Property( + property: 'domains', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'domain', type: 'string'), + new OA\Property(property: 'subscribers', type: 'integer'), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + ], + type: 'object', + nullable: true +)] +#[OA\Schema( + schema: 'DetailedDomainStats', + properties: [ + new OA\Property( + property: 'domains', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'domain', type: 'string'), + new OA\Property( + property: 'confirmed', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + new OA\Property( + property: 'unconfirmed', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + new OA\Property( + property: 'blacklisted', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + new OA\Property( + property: 'total', + properties: [ + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + ], + type: 'object', + nullable: true +)] class SwaggerSchemasResponse { } From 30b8e4820896823af6b43629e6add5d256500247 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 22:17:41 +0400 Subject: [PATCH 13/20] ISSUE-345: top domains stats --- config/services/normalizers.yml | 4 + .../Controller/AnalyticsController.php | 11 +- .../Serializer/TopDomainsNormalizer.php | 41 +++++++ .../Controller/AnalyticsControllerTest.php | 73 ++++++++++- .../Serializer/TopDomainsNormalizerTest.php | 113 ++++++++++++++++++ 5 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 src/Statistics/Serializer/TopDomainsNormalizer.php create mode 100644 tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 2ac511c..922e4a5 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -77,3 +77,7 @@ services: PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index 22aebe9..0c20e3f 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -11,6 +11,7 @@ use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; +use PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer; use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -27,18 +28,21 @@ class AnalyticsController extends BaseController private AnalyticsService $analyticsService; private CampaignStatisticsNormalizer $campaignStatsNormalizer; private ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer; + private TopDomainsNormalizer $topDomainsNormalizer; public function __construct( Authentication $authentication, RequestValidator $validator, AnalyticsService $analyticsService, CampaignStatisticsNormalizer $campaignStatsNormalizer, - ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer + ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer, + TopDomainsNormalizer $topDomainsNormalizer ) { parent::__construct($authentication, $validator); $this->analyticsService = $analyticsService; $this->campaignStatsNormalizer = $campaignStatsNormalizer; $this->viewOpensStatsNormalizer = $viewOpensStatsNormalizer; + $this->topDomainsNormalizer = $topDomainsNormalizer; } #[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])] @@ -245,8 +249,11 @@ public function getTopDomains(Request $request): JsonResponse $minSubscribers = (int) $request->query->get('min_subscribers', 5); $data = $this->analyticsService->getTopDomains($limit, $minSubscribers); + $normalizedData = $this->topDomainsNormalizer->normalize($data, null, [ + 'top_domains' => true, + ]); - return $this->json($data, Response::HTTP_OK); + return $this->json($normalizedData, Response::HTTP_OK); } #[Route('/domains/confirmation', name: 'domain_confirmation_statistics', methods: ['GET'])] diff --git a/src/Statistics/Serializer/TopDomainsNormalizer.php b/src/Statistics/Serializer/TopDomainsNormalizer.php new file mode 100644 index 0000000..62c4d05 --- /dev/null +++ b/src/Statistics/Serializer/TopDomainsNormalizer.php @@ -0,0 +1,41 @@ + $domain['domain'] ?? '', + 'subscribers' => $domain['subscribers'] ?? 0, + ]; + } + + return [ + 'domains' => $domains, + 'total' => $object['total'] ?? 0, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($context['top_domains']); + } +} diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php index 21e86bc..425ac10 100644 --- a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -42,7 +42,7 @@ public function testGetCampaignStatisticsWithExpiredSessionKeyReturnsForbidden() public function testGetCampaignStatisticsWithValidSessionReturnsOkay(): void { $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); - + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/campaigns'); $this->assertHttpOkay(); } @@ -68,7 +68,7 @@ public function testGetViewOpensStatisticsWithoutSessionKeyReturnsForbidden(): v public function testGetViewOpensStatisticsWithValidSessionReturnsOkay(): void { $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, MessageFixture::class]); - + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/view-opens'); $this->assertHttpOkay(); } @@ -96,7 +96,7 @@ public function testGetTopDomainsWithoutSessionKeyReturnsForbidden(): void public function testGetTopDomainsWithValidSessionReturnsOkay(): void { $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); - + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top'); $this->assertHttpOkay(); } @@ -111,6 +111,69 @@ public function testGetTopDomainsReturnsDomainsData(): void self::assertIsArray($response); self::assertArrayHasKey('domains', $response); self::assertArrayHasKey('total', $response); + self::assertIsArray($response['domains']); + self::assertIsInt($response['total']); + } + + public function testGetTopDomainsWithLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?limit=5'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); + self::assertLessThanOrEqual(5, count($response['domains'])); + } + + public function testGetTopDomainsWithMinSubscribersParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?min_subscribers=10'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); + + // Verify all domains have at least 10 subscribers + foreach ($response['domains'] as $domain) { + self::assertArrayHasKey('subscribers', $domain); + self::assertGreaterThanOrEqual(10, $domain['subscribers']); + } + } + + public function testGetTopDomainsWithBothParameters(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?limit=3&min_subscribers=10'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); + self::assertLessThanOrEqual(3, count($response['domains'])); + + foreach ($response['domains'] as $domain) { + self::assertArrayHasKey('subscribers', $domain); + self::assertGreaterThanOrEqual(10, $domain['subscribers']); + } + } + + public function testGetTopDomainsWithInvalidLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/top?limit=invalid'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('domains', $response); + self::assertIsArray($response['domains']); } public function testGetDomainConfirmationStatisticsWithoutSessionKeyReturnsForbidden(): void @@ -122,7 +185,7 @@ public function testGetDomainConfirmationStatisticsWithoutSessionKeyReturnsForbi public function testGetDomainConfirmationStatisticsWithValidSessionReturnsOkay(): void { $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); - + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/domains/confirmation'); $this->assertHttpOkay(); } @@ -148,7 +211,7 @@ public function testGetTopLocalPartsWithoutSessionKeyReturnsForbidden(): void public function testGetTopLocalPartsWithValidSessionReturnsOkay(): void { $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); - + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top'); $this->assertHttpOkay(); } diff --git a/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php b/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php new file mode 100644 index 0000000..486e27b --- /dev/null +++ b/tests/Unit/Statistics/Serializer/TopDomainsNormalizerTest.php @@ -0,0 +1,113 @@ + [ + ['domain' => 'example.com', 'subscribers' => 100], + ['domain' => 'test.org', 'subscribers' => 50], + ], + 'total' => 150, + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(150, $result['total']); + $this->assertCount(2, $result['domains']); + $this->assertEquals('example.com', $result['domains'][0]['domain']); + $this->assertEquals(100, $result['domains'][0]['subscribers']); + $this->assertEquals('test.org', $result['domains'][1]['domain']); + $this->assertEquals(50, $result['domains'][1]['subscribers']); + } + + public function testNormalizeWithMissingFields(): void + { + $data = [ + 'domains' => [ + ['domain' => 'example.com'], + ['subscribers' => 50], + [], + ], + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertCount(3, $result['domains']); + $this->assertEquals('example.com', $result['domains'][0]['domain']); + $this->assertEquals(0, $result['domains'][0]['subscribers']); + $this->assertEquals('', $result['domains'][1]['domain']); + $this->assertEquals(50, $result['domains'][1]['subscribers']); + $this->assertEquals('', $result['domains'][2]['domain']); + $this->assertEquals(0, $result['domains'][2]['subscribers']); + } + + public function testNormalizeWithEmptyDomains(): void + { + $data = [ + 'domains' => [], + 'total' => 0, + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertEmpty($result['domains']); + } + + public function testNormalizeWithNoDomains(): void + { + $data = [ + 'total' => 100, + ]; + + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('domains', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(100, $result['total']); + $this->assertEmpty($result['domains']); + } + + public function testNormalizeWithInvalidObject(): void + { + $normalizer = new TopDomainsNormalizer(); + $result = $normalizer->normalize('not an array'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testSupportsNormalization(): void + { + $normalizer = new TopDomainsNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization([], null, ['top_domains' => true])); + $this->assertFalse($normalizer->supportsNormalization([], null, [])); + $this->assertFalse($normalizer->supportsNormalization('not an array', null, ['top_domains' => true])); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), null, ['top_domains' => true])); + } +} From 34cab493b3be71fdab6d643c23ee4a46cea2ec17 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 22:31:29 +0400 Subject: [PATCH 14/20] ISSUE-345: top local parts stats --- config/services/normalizers.yml | 4 + .../Controller/AnalyticsController.php | 30 ++--- .../OpenApi/SwaggerSchemasResponse.php | 21 +++ .../Serializer/TopLocalPartsNormalizer.php | 42 ++++++ .../Controller/AnalyticsControllerTest.php | 29 ++++- .../Controller/AnalyticsControllerTest.php | 17 ++- .../TopLocalPartsNormalizerTest.php | 122 ++++++++++++++++++ 7 files changed, 242 insertions(+), 23 deletions(-) create mode 100644 src/Statistics/Serializer/TopLocalPartsNormalizer.php create mode 100644 tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 922e4a5..e27e1e4 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -81,3 +81,7 @@ services: PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer: tags: [ 'serializer.normalizer' ] autowire: true + + PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer: + tags: [ 'serializer.normalizer' ] + autowire: true diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index 0c20e3f..90fa4d3 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -12,6 +12,7 @@ use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; use PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer; +use PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer; use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -29,6 +30,7 @@ class AnalyticsController extends BaseController private CampaignStatisticsNormalizer $campaignStatsNormalizer; private ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer; private TopDomainsNormalizer $topDomainsNormalizer; + private TopLocalPartsNormalizer $topLocalPartsNormalizer; public function __construct( Authentication $authentication, @@ -36,13 +38,15 @@ public function __construct( AnalyticsService $analyticsService, CampaignStatisticsNormalizer $campaignStatsNormalizer, ViewOpensStatisticsNormalizer $viewOpensStatsNormalizer, - TopDomainsNormalizer $topDomainsNormalizer + TopDomainsNormalizer $topDomainsNormalizer, + TopLocalPartsNormalizer $topLocalPartsNormalizer ) { parent::__construct($authentication, $validator); $this->analyticsService = $analyticsService; $this->campaignStatsNormalizer = $campaignStatsNormalizer; $this->viewOpensStatsNormalizer = $viewOpensStatsNormalizer; $this->topDomainsNormalizer = $topDomainsNormalizer; + $this->topLocalPartsNormalizer = $topLocalPartsNormalizer; } #[Route('/campaigns', name: 'campaign_statistics', methods: ['GET'])] @@ -337,24 +341,7 @@ public function getDomainConfirmationStatistics(Request $request): JsonResponse new OA\Response( response: 200, description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'local_parts', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'local_part', type: 'string'), - new OA\Property(property: 'count', type: 'integer'), - new OA\Property(property: 'percentage', type: 'number', format: 'float'), - ], - type: 'object' - ) - ), - new OA\Property(property: 'total', type: 'integer'), - ], - type: 'object' - ) + content: new OA\JsonContent(ref: '#/components/schemas/LocalPartsStats') ), new OA\Response( response: 403, @@ -373,7 +360,10 @@ public function getTopLocalParts(Request $request): JsonResponse $limit = (int) $request->query->get('limit', 25); $data = $this->analyticsService->getTopLocalParts($limit); + $normalizedData = $this->topLocalPartsNormalizer->normalize($data, null, [ + 'top_local_parts' => true, + ]); - return $this->json($data, Response::HTTP_OK); + return $this->json($normalizedData, Response::HTTP_OK); } } diff --git a/src/Statistics/OpenApi/SwaggerSchemasResponse.php b/src/Statistics/OpenApi/SwaggerSchemasResponse.php index 898f9d9..dd7100b 100644 --- a/src/Statistics/OpenApi/SwaggerSchemasResponse.php +++ b/src/Statistics/OpenApi/SwaggerSchemasResponse.php @@ -103,6 +103,27 @@ type: 'object', nullable: true )] +#[OA\Schema( + schema: 'LocalPartsStats', + properties: [ + new OA\Property( + property: 'local_parts', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'local_part', type: 'string'), + new OA\Property(property: 'count', type: 'integer'), + new OA\Property(property: 'percentage', type: 'number', format: 'float'), + ], + type: 'object' + ) + ), + new OA\Property(property: 'total', type: 'integer'), + ], + type: 'object', + nullable: true +)] + class SwaggerSchemasResponse { } diff --git a/src/Statistics/Serializer/TopLocalPartsNormalizer.php b/src/Statistics/Serializer/TopLocalPartsNormalizer.php new file mode 100644 index 0000000..34ea851 --- /dev/null +++ b/src/Statistics/Serializer/TopLocalPartsNormalizer.php @@ -0,0 +1,42 @@ + $localPart['localPart'] ?? '', + 'count' => $localPart['count'] ?? 0, + 'percentage' => $localPart['percentage'] ?? 0.0, + ]; + } + + return [ + 'local_parts' => $localParts, + 'total' => $object['total'] ?? 0, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return is_array($data) && isset($context['top_local_parts']); + } +} diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php index 425ac10..6e23329 100644 --- a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -224,7 +224,34 @@ public function testGetTopLocalPartsReturnsLocalPartsData(): void $response = $this->getDecodedJsonResponseContent(); self::assertIsArray($response); - self::assertArrayHasKey('localParts', $response); + self::assertArrayHasKey('local_parts', $response); self::assertArrayHasKey('total', $response); + self::assertIsArray($response['local_parts']); + self::assertIsInt($response['total']); + } + + public function testGetTopLocalPartsWithLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top?limit=5'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('local_parts', $response); + self::assertIsArray($response['local_parts']); + self::assertLessThanOrEqual(5, count($response['local_parts'])); + } + + public function testGetTopLocalPartsWithInvalidLimitParameter(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class, SubscriberFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/local-parts/top?limit=invalid'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + self::assertArrayHasKey('local_parts', $response); + self::assertIsArray($response['local_parts']); } } diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php index eb072b7..452ff13 100644 --- a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -12,6 +12,8 @@ use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Statistics\Controller\AnalyticsController; use PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer; +use PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer; +use PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer; use PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer; use PhpList\RestBundle\Tests\Helpers\DummyAnalyticsController; use PHPUnit\Framework\MockObject\MockObject; @@ -36,12 +38,15 @@ protected function setUp(): void $this->analyticsService = $this->createMock(AnalyticsService::class); $campaignStatisticsNormalizer = new CampaignStatisticsNormalizer(); $viewOpensStatisticsNormalizer = new ViewOpensStatisticsNormalizer(); + $topDomainsNormalizer = new TopDomainsNormalizer(); $this->controller = new DummyAnalyticsController( $this->authentication, $validator, $this->analyticsService, $campaignStatisticsNormalizer, $viewOpensStatisticsNormalizer, + $topDomainsNormalizer, + new TopLocalPartsNormalizer() ); $this->privileges = $this->createMock(Privileges::class); @@ -425,8 +430,16 @@ public function testGetTopLocalPartsReturnsJsonResponse(): void $response = $this->controller->getTopLocalParts($request); - self::assertInstanceOf(JsonResponse::class, $response); self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); - self::assertEquals($expectedData, json_decode($response->getContent(), true)); + self::assertEquals([ + 'local_parts' => [ + [ + 'local_part' => 'info', + 'count' => 30, + 'percentage' => 60.0, + ] + ], + 'total' => 1, + ], json_decode($response->getContent(), true)); } } diff --git a/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php b/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php new file mode 100644 index 0000000..a08f80f --- /dev/null +++ b/tests/Unit/Statistics/Serializer/TopLocalPartsNormalizerTest.php @@ -0,0 +1,122 @@ + [ + ['localPart' => 'john', 'count' => 100, 'percentage' => 40.0], + ['localPart' => 'info', 'count' => 50, 'percentage' => 20.0], + ], + 'total' => 250, + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(250, $result['total']); + $this->assertCount(2, $result['local_parts']); + $this->assertEquals('john', $result['local_parts'][0]['local_part']); + $this->assertEquals(100, $result['local_parts'][0]['count']); + $this->assertEquals(40.0, $result['local_parts'][0]['percentage']); + $this->assertEquals('info', $result['local_parts'][1]['local_part']); + $this->assertEquals(50, $result['local_parts'][1]['count']); + $this->assertEquals(20.0, $result['local_parts'][1]['percentage']); + } + + public function testNormalizeWithMissingFields(): void + { + $data = [ + 'localParts' => [ + ['localPart' => 'john'], + ['count' => 50], + ['percentage' => 20.0], + [], + ], + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertCount(4, $result['local_parts']); + $this->assertEquals('john', $result['local_parts'][0]['local_part']); + $this->assertEquals(0, $result['local_parts'][0]['count']); + $this->assertEquals(0.0, $result['local_parts'][0]['percentage']); + $this->assertEquals('', $result['local_parts'][1]['local_part']); + $this->assertEquals(50, $result['local_parts'][1]['count']); + $this->assertEquals(0.0, $result['local_parts'][1]['percentage']); + $this->assertEquals('', $result['local_parts'][2]['local_part']); + $this->assertEquals(0, $result['local_parts'][2]['count']); + $this->assertEquals(20.0, $result['local_parts'][2]['percentage']); + $this->assertEquals('', $result['local_parts'][3]['local_part']); + $this->assertEquals(0, $result['local_parts'][3]['count']); + $this->assertEquals(0.0, $result['local_parts'][3]['percentage']); + } + + public function testNormalizeWithEmptyLocalParts(): void + { + $data = [ + 'localParts' => [], + 'total' => 0, + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(0, $result['total']); + $this->assertEmpty($result['local_parts']); + } + + public function testNormalizeWithNoLocalParts(): void + { + $data = [ + 'total' => 100, + ]; + + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize($data); + + $this->assertIsArray($result); + $this->assertArrayHasKey('local_parts', $result); + $this->assertArrayHasKey('total', $result); + $this->assertEquals(100, $result['total']); + $this->assertEmpty($result['local_parts']); + } + + public function testNormalizeWithInvalidObject(): void + { + $normalizer = new TopLocalPartsNormalizer(); + $result = $normalizer->normalize('not an array'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testSupportsNormalization(): void + { + $normalizer = new TopLocalPartsNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization([], null, ['top_local_parts' => true])); + $this->assertFalse($normalizer->supportsNormalization([], null, [])); + $this->assertFalse($normalizer->supportsNormalization('not an array', null, ['top_local_parts' => true])); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), null, ['top_local_parts' => true])); + } +} From 10e89c0d991202d223a0babaab6bd91bf647b291 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 22:48:19 +0400 Subject: [PATCH 15/20] ISSUE-345: style --- tests/Unit/Messaging/Service/CampaignServiceTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php index c7b7a4a..5293f0f 100644 --- a/tests/Unit/Messaging/Service/CampaignServiceTest.php +++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php @@ -17,6 +17,7 @@ use PhpList\RestBundle\Messaging\Request\UpdateMessageRequest; use PhpList\RestBundle\Messaging\Serializer\MessageNormalizer; use PhpList\RestBundle\Messaging\Service\CampaignService; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -24,9 +25,9 @@ class CampaignServiceTest extends TestCase { - private MessageManager $messageManager; - private PaginatedDataProvider $paginatedProvider; - private MessageNormalizer $normalizer; + private MessageManager|MockObject $messageManager; + private PaginatedDataProvider|MockObject $paginatedProvider; + private MessageNormalizer|MockObject $normalizer; private CampaignService $campaignService; protected function setUp(): void From 03da801be74bc7afaa5ea4a0185a68517513d439 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 22:58:21 +0400 Subject: [PATCH 16/20] ISSUE-345: note --- composer.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 90eacec..01dd819 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,17 @@ { "name": "Xheni Myrtaj", "email": "xheni@phplist.com", - "role": "Maintainer" + "role": "Former developer" }, { "name": "Oliver Klee", "email": "oliver@phplist.com", "role": "Former developer" + }, + { + "name": "Tatevik Grigoryan", + "email": "tatevik@phplist.com", + "role": "Maintainer" } ], "support": { From e2fdf7b54519b0276ef560e4820df75797b8a19c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 23:03:44 +0400 Subject: [PATCH 17/20] ISSUE-345: do not publish fos --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 01dd819..3bc9442 100644 --- a/composer.json +++ b/composer.json @@ -98,7 +98,6 @@ "symfony-tests-dir": "tests", "phplist/core": { "bundles": [ - "FOS\\RestBundle\\FOSRestBundle", "PhpList\\RestBundle\\PhpListRestBundle" ], "routes": { From d524ab515e3f78d5c90ac46fe52d490c786ffd4e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 23:30:07 +0400 Subject: [PATCH 18/20] ISSUE-345: AccessDeniedException --- src/Common/EventListener/ExceptionListener.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index d39b8f3..6db218f 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -11,6 +11,7 @@ use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Validator\Exception\ValidatorException; class ExceptionListener @@ -46,6 +47,11 @@ public function onKernelException(ExceptionEvent $event): void 'message' => $exception->getMessage(), ], 400); $event->setResponse($response); + } elseif ($exception instanceof AccessDeniedException) { + $response = new JsonResponse([ + 'message' => $exception->getMessage(), + ], 403); + $event->setResponse($response); } elseif ($exception instanceof Exception) { $response = new JsonResponse([ 'message' => $exception->getMessage(), From df585fc4f3073c7661c18a71cd102903e87625bd Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 2 Jul 2025 07:37:48 +0400 Subject: [PATCH 19/20] ISSUE-345: version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3bc9442..faf49db 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-ISSUE-345", + "phplist/core": "v5.0.0-alpha8", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", From ab46cfd78fd6944a089d554a9fa3b9ee6868de3c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 2 Jul 2025 08:01:15 +0400 Subject: [PATCH 20/20] ISSUE-345: test fix --- .../Subscription/Controller/SubscriberControllerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php index 240cfa9..2ee4c22 100644 --- a/tests/Integration/Subscription/Controller/SubscriberControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscriberControllerTest.php @@ -149,7 +149,7 @@ public function testPostSubscribersWithValidSessionKeyAssignsProvidedSubscriberD $email = 'subscriber@example.com'; $jsonData = [ 'email' => $email, - 'requestConfirmation' => true, + 'requestConfirmation' => false, 'blacklisted' => true, 'htmlEmail' => true, 'disabled' => true, @@ -160,7 +160,7 @@ public function testPostSubscribersWithValidSessionKeyAssignsProvidedSubscriberD $responseContent = $this->getDecodedJsonResponseContent(); static::assertSame($email, $responseContent['email']); - static::assertFalse($responseContent['confirmed']); + static::assertTrue($responseContent['confirmed']); static::assertFalse($responseContent['blacklisted']); static::assertTrue($responseContent['html_email']); static::assertFalse($responseContent['disabled']);