diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 170b9894..fa1d96c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: export PHPLIST_DATABASE_PASSWORD=${{ env.DB_PASSWORD }} export PHPLIST_DATABASE_PORT=${{ job.services.mysql.ports['3306'] }} export PHPLIST_DATABASE_HOST=127.0.0.1 + export PHPLIST_DATABASE_PATH= vendor/bin/phpunit tests/Integration/ continue-on-error: ${{matrix.php-versions == '8.0' }} # [temp-php8] - name: Running static analysis diff --git a/.gitignore b/.gitignore index 2c98e37b..a2318880 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ /var/ /vendor/ .phpunit.result.cache +.env +.env.dist diff --git a/composer.json b/composer.json index 17764d64..29c904a8 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", @@ -85,6 +85,7 @@ "PhpList\\Core\\Composer\\ScriptHandler::createGeneralConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::createBundleConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::createRoutesConfiguration", + "PhpList\\Core\\Composer\\ScriptHandler::createDotenvConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::createParametersConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::clearAllCaches" ], diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 78ea4f9a..692cf4d0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,10 +5,11 @@ xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.2/phpunit.xsd" backupGlobals="false" colors="true" - bootstrap="vendor/autoload.php" + bootstrap="tests/bootstrap.php" > + diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index 184925a3..bdc183d4 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -57,7 +57,7 @@ public function onKernelException(ExceptionEvent $event): void new JsonResponse([ 'message' => 'Validation failed', 'errors' => $this->parseFlatValidationMessage($exception->getMessage()), - ], 422) + ], 422) ); return; diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index 1769aa7f..c4ff688a 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -74,6 +74,21 @@ public function __construct( required: false, schema: new OA\Schema(type: 'string', maxLength: 50) ), + new OA\Parameter( + name: 'status', + description: 'Filter by one or more comma-separated statuses: ' . + 'draft, prepared, submitted, inprocess, sent, suspended', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'sort', + description: 'Sort direction by campaign id - desc returns newest campaigns first', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']) + ), ], responses: [ new OA\Response( diff --git a/src/Messaging/Controller/TemplateController.php b/src/Messaging/Controller/TemplateController.php index 9c272566..7dff67f7 100644 --- a/src/Messaging/Controller/TemplateController.php +++ b/src/Messaging/Controller/TemplateController.php @@ -116,6 +116,68 @@ className: Template::class, ); } + #[Route('', name: 'create', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/templates', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns a JSON response of created template.', + summary: 'Create a new template.', + requestBody: new OA\RequestBody( + description: 'Pass session credentials', + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema(ref: '#/components/schemas/CreateTemplateRequest') + ) + ), + tags: ['templates'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Template') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 409, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/AlreadyExistsResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function createTemplates(Request $request): JsonResponse + { + $this->requireAuthentication($request); + + /** @var CreateTemplateRequest $createTemplateRequest */ + $createTemplateRequest = $this->validator->validate($request, CreateTemplateRequest::class); + $template = $this->templateManager->create($createTemplateRequest->getDto()); + $this->entityManager->flush(); + + return $this->json( + $this->normalizer->normalize($template), + Response::HTTP_CREATED + ); + } + #[Route('/defaults', name: 'get_defaults', methods: ['GET'])] #[OA\Get( path: '/api/v2/templates/defaults', @@ -263,66 +325,6 @@ public function getTemplate( return $this->json($this->normalizer->normalize($template), Response::HTTP_OK); } - #[Route('', name: 'create', methods: ['POST'])] - #[OA\Post( - path: '/api/v2/templates', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Returns a JSON response of created template.', - summary: 'Create a new template.', - requestBody: new OA\RequestBody( - description: 'Pass session credentials', - required: true, - content: new OA\MediaType( - mediaType: 'multipart/form-data', - schema: new OA\Schema(ref: '#/components/schemas/UpdateTemplateRequest') - ) - ), - tags: ['templates'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - ], - responses: [ - new OA\Response( - response: 201, - description: 'Success', - content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Template') - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ), - new OA\Response( - response: 422, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') - ), - ] - )] - public function createTemplates(Request $request): JsonResponse - { - $this->requireAuthentication($request); - - /** @var CreateTemplateRequest $createTemplateRequest */ - $createTemplateRequest = $this->validator->validate($request, CreateTemplateRequest::class); - $template = $this->templateManager->create($createTemplateRequest->getDto()); - $this->entityManager->flush(); - - return $this->json( - $this->normalizer->normalize($template), - Response::HTTP_CREATED - ); - } - #[Route('/{templateId}', name: 'update', methods: ['PUT'])] #[OA\Put( path: '/api/v2/templates/{templateId}', diff --git a/src/Messaging/Request/Message/MessageMetadataRequest.php b/src/Messaging/Request/Message/MessageMetadataRequest.php index 2ae5ba3e..3eb29249 100644 --- a/src/Messaging/Request/Message/MessageMetadataRequest.php +++ b/src/Messaging/Request/Message/MessageMetadataRequest.php @@ -17,7 +17,7 @@ new OA\Property( property: 'status', type: 'string', - enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'], + enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended'], example: 'draft' ), ], @@ -26,7 +26,7 @@ enum: ['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'], class MessageMetadataRequest implements RequestDtoInterface, RequestInterface { #[Assert\NotBlank] - #[Assert\Choice(['draft', 'sent', 'prepared', 'submitted', 'suspended', 'requeued'])] + #[Assert\Choice(['draft', 'sent', 'prepared', 'submitted', 'suspended'])] public string $status; /** diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index 5f50124b..e2ea8e78 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -32,7 +32,13 @@ public function getMessages(Request $request, Administrator $administrator): arr { $filter = (new MessageFilter()) ->setOwner($administrator) - ->setSubject($request->query->get('subject')); + ->setSubject($request->query->get('subject')) + ->setStatus($request->query->get('status')); + + $sort = $request->query->get('sort'); + if (in_array($sort, ['asc', 'desc'], true)) { + $filter->setSortOrder($sort); + } return $this->paginatedProvider->getPaginatedList( request: $request, diff --git a/src/Statistics/Controller/AnalyticsController.php b/src/Statistics/Controller/AnalyticsController.php index be8e9968..c649e712 100644 --- a/src/Statistics/Controller/AnalyticsController.php +++ b/src/Statistics/Controller/AnalyticsController.php @@ -358,12 +358,12 @@ public function getTopLocalParts(Request $request): JsonResponse return $this->json($normalizedData, Response::HTTP_OK); } - #[Route('/dashboard', name: 'dashboard_statistics', methods: ['GET'])] + #[Route('/dashboard/summary', name: 'dashboard_summary', methods: ['GET'])] #[OA\Get( - path: '/api/v2/analytics/dashboard', + path: '/api/v2/analytics/dashboard/summary', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Returns dashboard cards with aggregate analytics metrics.', - summary: 'Gets dashboard analytics statistics.', + 'Returns dashboard summary statistics.', + summary: 'Gets dashboard summary statistics.', tags: ['analytics'], parameters: [ new OA\Parameter( @@ -381,114 +381,66 @@ public function getTopLocalParts(Request $request): JsonResponse content: new OA\JsonContent( properties: [ new OA\Property( - property: 'summary_statistics', + property: 'total_subscribers', properties: [ + new OA\Property(property: 'value', type: 'integer', example: 48294), new OA\Property( - property: 'total_subscribers', - properties: [ - new OA\Property(property: 'value', type: 'integer', example: 48294), - new OA\Property( - property: 'change_vs_last_month', - type: 'number', - format: 'float', - example: 12.5 - ), - ], - type: 'object' + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 12.5 ), + ], + type: 'object' + ), + new OA\Property( + property: 'active_campaigns', + properties: [ + new OA\Property(property: 'value', type: 'integer', example: 12), new OA\Property( - property: 'active_campaigns', - properties: [ - new OA\Property(property: 'value', type: 'integer', example: 12), - new OA\Property( - property: 'change_vs_last_month', - type: 'number', - format: 'float', - example: 0 - ), - ], - type: 'object' + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 0 ), + ], + type: 'object' + ), + new OA\Property( + property: 'open_rate', + properties: [ new OA\Property( - property: 'open_rate', - properties: [ - new OA\Property( - property: 'value', - type: 'number', - format: 'float', - example: 12 - ), - new OA\Property( - property: 'change_vs_last_month', - type: 'number', - format: 'float', - example: 0 - ), - ], - type: 'object' + property: 'value', + type: 'number', + format: 'float', + example: 12 ), new OA\Property( - property: 'bounce_rate', - properties: [ - new OA\Property( - property: 'value', - type: 'number', - format: 'float', - example: 12 - ), - new OA\Property( - property: 'change_vs_last_month', - type: 'number', - format: 'float', - example: 0 - ), - ], - type: 'object' + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 0 ), ], type: 'object' ), new OA\Property( - property: 'recent_campaigns', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'name', type: 'string', example: 'March Newsletter'), - new OA\Property( - property: 'status', - type: 'string', - example: 'sent', - nullable: true - ), - new OA\Property( - property: 'date', - type: 'string', - format: 'date', - example: '2026-03-15', - nullable: true - ), - new OA\Property(property: 'open_rate', type: 'string', example: '42.50%'), - new OA\Property(property: 'click_rate', type: 'string', example: '8.10%'), - ], - type: 'object' - ) - ), - new OA\Property( - property: 'campaign_performance', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property( - property: 'date', - type: 'string', - format: 'date', - example: '2026-03-19' - ), - new OA\Property(property: 'opens', type: 'integer', example: 234), - new OA\Property(property: 'clicks', type: 'integer', example: 57), - ], - type: 'object' - ) + property: 'bounce_rate', + properties: [ + new OA\Property( + property: 'value', + type: 'number', + format: 'float', + example: 12 + ), + new OA\Property( + property: 'change_vs_last_month', + type: 'number', + format: 'float', + example: 0 + ), + ], + type: 'object' ), ], type: 'object' @@ -501,16 +453,126 @@ public function getTopLocalParts(Request $request): JsonResponse ) ] )] - public function getDashboardStatistics(Request $request): JsonResponse + public function getDashboardSummary(Request $request): JsonResponse + { + $this->requireAuthentication($request); + + $data = $this->analyticsService->getSummaryStatistics(); + + return $this->json($data, Response::HTTP_OK); + } + + #[Route('/dashboard/recent-campaigns', name: 'dashboard_recent_campaigns', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/analytics/dashboard/recent-campaigns', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns the most recent campaigns with their performance metrics.', + summary: 'Gets dashboard recent campaigns statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'name', type: 'string', example: 'March Newsletter'), + new OA\Property( + property: 'status', + type: 'string', + example: 'sent', + nullable: true + ), + new OA\Property( + property: 'date', + type: 'string', + format: 'date', + example: '2026-03-15', + nullable: true + ), + new OA\Property(property: 'open_rate', type: 'string', example: '42.50%'), + new OA\Property(property: 'click_rate', type: 'string', example: '8.10%'), + ], + type: 'object' + ) + ) + ), + new OA\Response( + response: 401, + description: 'Not authenticated', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getRecentCampaignsStatistics(Request $request): JsonResponse + { + $this->requireAuthentication($request); + + $data = $this->analyticsService->getRecentCampaigns(); + + return $this->json($data, Response::HTTP_OK); + } + + #[Route('/dashboard/performance', name: 'dashboard_performance', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/analytics/dashboard/performance', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns campaign performance metrics over time.', + summary: 'Gets dashboard campaign performance statistics.', + tags: ['analytics'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property( + property: 'date', + type: 'string', + format: 'date', + example: '2026-03-19' + ), + new OA\Property(property: 'opens', type: 'integer', example: 234), + new OA\Property(property: 'clicks', type: 'integer', example: 57), + ], + type: 'object' + ) + ) + ), + new OA\Response( + response: 401, + description: 'Not authenticated', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ) + ] + )] + public function getCampaignPerformanceStatistics(Request $request): JsonResponse { $this->requireAuthentication($request); - $response = [ - 'summary_statistics' => $this->analyticsService->getSummaryStatistics(), - 'recent_campaigns' => $this->analyticsService->getRecentCampaigns(), - 'campaign_performance' => $this->analyticsService->getCampaignPerformance(), - ]; + $data = $this->analyticsService->getCampaignPerformance(); - return $this->json($response, Response::HTTP_OK); + return $this->json($data, Response::HTTP_OK); } } diff --git a/tests/Integration/Common/Routing/RoutingTest.php b/tests/Integration/Common/Routing/RoutingTest.php index cfbe3cc3..bbab5c77 100644 --- a/tests/Integration/Common/Routing/RoutingTest.php +++ b/tests/Integration/Common/Routing/RoutingTest.php @@ -16,7 +16,7 @@ class RoutingTest extends WebTestCase public function testRootUrlHasHtmlContentType() { $client = self::createClient(); - $client->request('get', '/api/v2'); + $client->request('GET', '/api/v2', server: ['HTTP_ACCEPT' => 'text/html']); $response = $client->getResponse(); diff --git a/tests/Integration/Composer/ScriptsTest.php b/tests/Integration/Composer/ScriptsTest.php index 5b0df800..2cd0691b 100644 --- a/tests/Integration/Composer/ScriptsTest.php +++ b/tests/Integration/Composer/ScriptsTest.php @@ -29,9 +29,7 @@ private function getAbsolutePublicDirectoryPath(): string public static function publicDirectoryFilesDataProvider(): array { return [ - 'production entry point' => ['app.php'], - 'development entry point' => ['app_dev.php'], - 'testing entry point' => ['app_test.php'], + 'entry point' => ['index.php'], '.htaccess' => ['.htaccess'], ]; } diff --git a/tests/Integration/Messaging/Controller/CampaignControllerTest.php b/tests/Integration/Messaging/Controller/CampaignControllerTest.php index 0cadb2d6..6cfd8cfc 100644 --- a/tests/Integration/Messaging/Controller/CampaignControllerTest.php +++ b/tests/Integration/Messaging/Controller/CampaignControllerTest.php @@ -87,4 +87,48 @@ public function testDeleteCampaignReturnsNoContent(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/campaigns/1'); $this->assertHttpNoContent(); } + + public function testGetCampaignsFiltersBySingleStatus(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns?status=sent'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertCount(1, $response['items']); + self::assertSame(1, $response['items'][0]['id']); + } + + public function testGetCampaignsFiltersByCommaSeparatedStatuses(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns?status=submitted,draft'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertCount(1, $response['items']); + self::assertSame(2, $response['items'][0]['id']); + } + + public function testGetCampaignsSortsDescendingWhenRequested(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns?sort=desc'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertSame(2, $response['items'][0]['id']); + self::assertSame(1, $response['items'][1]['id']); + } + + public function testGetCampaignsDefaultsToAscendingOrder(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/campaigns'); + $response = $this->getDecodedJsonResponseContent(); + + self::assertSame(1, $response['items'][0]['id']); + self::assertSame(2, $response['items'][1]['id']); + } } diff --git a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php index 18ee8c19..b0c05ebe 100644 --- a/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Integration/Statistics/Controller/AnalyticsControllerTest.php @@ -255,13 +255,13 @@ public function testGetTopLocalPartsWithInvalidLimitParameter(): void self::assertIsArray($response['local_parts']); } - public function testGetDashboardStatisticsWithoutSessionKeyReturnsUnauthorized(): void + public function testGetDashboardSummaryWithoutSessionKeyReturnsUnauthorized(): void { - self::getClient()->request('GET', '/api/v2/analytics/dashboard'); + self::getClient()->request('GET', '/api/v2/analytics/dashboard/summary'); $this->assertHttpUnauthorized(); } - public function testGetDashboardStatisticsWithValidSessionReturnsCardsData(): void + public function testGetDashboardSummaryWithValidSessionReturnsCardsData(): void { $this->loadFixtures([ AdministratorFixture::class, @@ -270,21 +270,62 @@ public function testGetDashboardStatisticsWithValidSessionReturnsCardsData(): vo MessageFixture::class, ]); - $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard'); + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard/summary'); $this->assertHttpOkay(); $response = $this->getDecodedJsonResponseContent(); self::assertIsArray($response); - self::assertArrayHasKey('summary_statistics', $response); - self::assertArrayHasKey('recent_campaigns', $response); - self::assertArrayHasKey('campaign_performance', $response); foreach (['total_subscribers', 'active_campaigns', 'open_rate', 'bounce_rate'] as $metric) { - self::assertIsArray($response['summary_statistics'][$metric]); - self::assertArrayHasKey('value', $response['summary_statistics'][$metric]); - self::assertArrayHasKey('change_vs_last_month', $response['summary_statistics'][$metric]); - self::assertIsNumeric($response['summary_statistics'][$metric]['value']); - self::assertIsNumeric($response['summary_statistics'][$metric]['change_vs_last_month']); + self::assertIsArray($response[$metric]); + self::assertArrayHasKey('value', $response[$metric]); + self::assertArrayHasKey('change_vs_last_month', $response[$metric]); + self::assertIsNumeric($response[$metric]['value']); + self::assertIsNumeric($response[$metric]['change_vs_last_month']); } } + + public function testGetRecentCampaignsStatisticsWithoutSessionKeyReturnsUnauthorized(): void + { + self::getClient()->request('GET', '/api/v2/analytics/dashboard/recent-campaigns'); + $this->assertHttpUnauthorized(); + } + + public function testGetRecentCampaignsStatisticsWithValidSessionReturnsCampaignsData(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscriberFixture::class, + MessageFixture::class, + ]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard/recent-campaigns'); + $this->assertHttpOkay(); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + } + + public function testGetCampaignPerformanceStatisticsWithoutSessionKeyReturnsUnauthorized(): void + { + self::getClient()->request('GET', '/api/v2/analytics/dashboard/performance'); + $this->assertHttpUnauthorized(); + } + + public function testGetCampaignPerformanceStatisticsWithValidSessionReturnsPerformanceData(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscriberFixture::class, + MessageFixture::class, + ]); + + $this->authenticatedJsonRequest('GET', '/api/v2/analytics/dashboard/performance'); + $this->assertHttpOkay(); + $response = $this->getDecodedJsonResponseContent(); + + self::assertIsArray($response); + } } diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php index e328fe97..44a83441 100644 --- a/tests/Unit/Messaging/Service/CampaignServiceTest.php +++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php @@ -68,6 +68,52 @@ public function testGetMessagesReturnsExpectedResult(): void $this->assertSame($expectedResult, $result); } + public function testGetMessagesAppliesStatusAndSortFromQuery(): void + { + $request = new Request(query: ['status' => 'submitted,prepared', 'sort' => 'desc']); + $administrator = $this->createMock(Administrator::class); + $expectedResult = ['items' => [], 'pagination' => []]; + + $this->paginatedProvider->expects($this->once()) + ->method('getPaginatedList') + ->with( + $this->identicalTo($request), + $this->identicalTo($this->normalizer), + Message::class, + $this->callback(function (MessageFilter $filter) { + return $filter->getStatus() === 'submitted,prepared' && $filter->getSortOrder() === 'desc'; + }) + ) + ->willReturn($expectedResult); + + $result = $this->campaignService->getMessages($request, $administrator); + + $this->assertSame($expectedResult, $result); + } + + public function testGetMessagesIgnoresInvalidSortValue(): void + { + $request = new Request(query: ['sort' => 'bogus']); + $administrator = $this->createMock(Administrator::class); + $expectedResult = ['items' => [], 'pagination' => []]; + + $this->paginatedProvider->expects($this->once()) + ->method('getPaginatedList') + ->with( + $this->identicalTo($request), + $this->identicalTo($this->normalizer), + Message::class, + $this->callback(function (MessageFilter $filter) { + return $filter->getSortOrder() === 'asc'; + }) + ) + ->willReturn($expectedResult); + + $result = $this->campaignService->getMessages($request, $administrator); + + $this->assertSame($expectedResult, $result); + } + public function testGetMessageThrowsExceptionWhenMessageIsNull(): void { $this->expectException(NotFoundHttpException::class); diff --git a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php index 6bbdc7d2..401da045 100644 --- a/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php +++ b/tests/Unit/Statistics/Controller/AnalyticsControllerTest.php @@ -443,7 +443,7 @@ public function testGetTopLocalPartsReturnsJsonResponse(): void ], json_decode($response->getContent(), true)); } - public function testGetDashboardStatisticsWithoutStatisticsPrivilegeDoesNotThrowException(): void + public function testGetDashboardSummaryDoesNotCheckStatisticsPrivilege(): void { $request = new Request(); @@ -456,27 +456,7 @@ public function testGetDashboardStatisticsWithoutStatisticsPrivilegeDoesNotThrow $this->privileges ->expects(self::never()) ->method('has') - ->with(PrivilegeFlag::Statistics) - ->willReturn(false); - - $this->controller->getDashboardStatistics($request); - } - - public function testGetDashboardStatisticsReturnsJsonResponse(): void - { - $request = new Request(); - - $this->authentication - ->expects(self::once()) - ->method('authenticateByApiKey') - ->with($request) - ->willReturn($this->administrator); - - $this->privileges - ->expects(self::never()) - ->method('has') - ->with(PrivilegeFlag::Statistics) - ->willReturn(true); + ->with(PrivilegeFlag::Statistics); $this->analyticsService ->expects(self::once()) @@ -500,30 +480,98 @@ public function testGetDashboardStatisticsReturnsJsonResponse(): void ], ]); - $response = $this->controller->getDashboardStatistics($request); + $response = $this->controller->getDashboardSummary($request); self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); self::assertEquals([ - 'summary_statistics' => [ - 'total_subscribers' => [ - 'value' => 80, - 'change_vs_last_month' => 10.5, - ], - 'active_campaigns' => [ - 'value' => 12, - 'change_vs_last_month' => -4.25, - ], - 'open_rate' => [ - 'value' => 40.0, - 'change_vs_last_month' => 3.3, - ], - 'bounce_rate' => [ - 'value' => 6.67, - 'change_vs_last_month' => -1.1, - ], + 'total_subscribers' => [ + 'value' => 80, + 'change_vs_last_month' => 10.5, + ], + 'active_campaigns' => [ + 'value' => 12, + 'change_vs_last_month' => -4.25, + ], + 'open_rate' => [ + 'value' => 40.0, + 'change_vs_last_month' => 3.3, + ], + 'bounce_rate' => [ + 'value' => 6.67, + 'change_vs_last_month' => -1.1, ], - 'recent_campaigns' => [], - 'campaign_performance' => [], ], json_decode($response->getContent(), true)); } + + public function testGetRecentCampaignsStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::never()) + ->method('has') + ->with(PrivilegeFlag::Statistics); + + $expectedData = [ + [ + 'name' => 'March Newsletter', + 'status' => 'sent', + 'date' => '2026-03-15', + 'open_rate' => '42.50%', + 'click_rate' => '8.10%', + ], + ]; + + $this->analyticsService + ->expects(self::once()) + ->method('getRecentCampaigns') + ->willReturn($expectedData); + + $response = $this->controller->getRecentCampaignsStatistics($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } + + public function testGetCampaignPerformanceStatisticsReturnsJsonResponse(): void + { + $request = new Request(); + + $this->authentication + ->expects(self::once()) + ->method('authenticateByApiKey') + ->with($request) + ->willReturn($this->administrator); + + $this->privileges + ->expects(self::never()) + ->method('has') + ->with(PrivilegeFlag::Statistics); + + $expectedData = [ + [ + 'date' => '2026-03-19', + 'opens' => 234, + 'clicks' => 57, + ], + ]; + + $this->analyticsService + ->expects(self::once()) + ->method('getCampaignPerformance') + ->willReturn($expectedData); + + $response = $this->controller->getCampaignPerformanceStatistics($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); + self::assertEquals($expectedData, json_decode($response->getContent(), true)); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..8a148f7f --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,11 @@ +bootEnv(dirname(__DIR__) . '/.env'); +}