From 784bb28794e6947cd2deec1e1eff36fb38399977 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 30 Apr 2026 14:09:53 +0400 Subject: [PATCH 01/18] Fix: swagger response --- src/Messaging/Controller/BounceController.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Messaging/Controller/BounceController.php b/src/Messaging/Controller/BounceController.php index 3ab07942..30587aa1 100644 --- a/src/Messaging/Controller/BounceController.php +++ b/src/Messaging/Controller/BounceController.php @@ -81,8 +81,15 @@ public function __construct( response: 200, description: 'Success', content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/BounceView') + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/BounceView') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' ) ), new OA\Response( From addb913dae4f2f90721bd8110752bb0426d118b2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 18 May 2026 13:04:53 +0400 Subject: [PATCH 02/18] Add workflow for updating OpenAPI specs in web frontend and limit client-docs to specific branches --- .github/workflows/client-docs.yml | 5 +- .github/workflows/front-docs.yml | 120 ++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/front-docs.yml diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index b961c1e9..33eee07d 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -3,8 +3,11 @@ name: Update phplist-api-client OpenAPI on: push: branches: - - '**' + - dev + - main pull_request: + branches: + - main jobs: generate-openapi: diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml new file mode 100644 index 00000000..7a6d845d --- /dev/null +++ b/.github/workflows/front-docs.yml @@ -0,0 +1,120 @@ +name: Update phplist-web-frontend OpenAPI + +on: + push: + branches: + - dev + - main + pull_request: + branches: + - main +jobs: + generate-openapi: + runs-on: ubuntu-22.04 + outputs: + source_branch: ${{ steps.branch.outputs.source_branch }} + steps: + - name: Determine source branch + id: branch + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "source_branch=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + else + echo "source_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout Source Repository + uses: actions/checkout@v3 + + - name: Setup PHP with Composer and Extensions + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: mbstring, dom, fileinfo, mysql + + - name: Cache Composer Dependencies + uses: actions/cache@v3 + with: + path: ~/.composer/cache + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install Composer Dependencies + run: composer install --no-interaction --prefer-dist + + - name: Generate OpenAPI Specification JSON + run: vendor/bin/openapi -o docs/latest-restapi.json --format json src + + - name: Upload OpenAPI Artifact + uses: actions/upload-artifact@v4 + with: + name: openapi-json + path: docs/latest-restapi.json + + update-web-frontend: + runs-on: ubuntu-22.04 + needs: generate-openapi + env: + TARGET_BRANCH: ${{ needs.generate-openapi.outputs.source_branch }} + steps: + - name: Checkout phplist-web-frontend Repository + uses: actions/checkout@v3 + with: + repository: phplist/phplist-web-frontend + token: ${{ secrets.PUSH_WEB_FRONTEND }} + fetch-depth: 0 + + - name: Prepare target branch + run: | + git fetch origin + + if git ls-remote --exit-code --heads origin "$TARGET_BRANCH" >/dev/null 2>&1; then + git checkout "$TARGET_BRANCH" + git pull --rebase origin "$TARGET_BRANCH" + else + git checkout -b "$TARGET_BRANCH" + fi + + - name: Download Generated OpenAPI JSON + uses: actions/download-artifact@v4 + with: + name: openapi-json + path: ./new-openapi + + - name: Compare and Check for Differences + id: diff + run: | + # Compare the openapi files if old exists, else always deploy + if [ -f openapi.json ]; then + diff openapi.json new-openapi/latest-restapi.json > openapi-diff.txt || true + if [ -s openapi-diff.txt ]; then + echo "diff=true" >> "$GITHUB_OUTPUT" + else + echo "diff=false" >> "$GITHUB_OUTPUT" + fi + else + echo "No previous openapi.json, will add." + echo "diff=true" >> "$GITHUB_OUTPUT" + fi + + - name: Update and Commit OpenAPI File + if: steps.diff.outputs.diff == 'true' + run: | + set -euo pipefail + cp new-openapi/latest-restapi.json openapi.json + git config user.name "github-actions" + git config user.email "github-actions@web-frontend.workflow" + git add openapi.json + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + git commit -m "Update openapi.json from web frontend workflow $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + git fetch origin "$TARGET_BRANCH" + git rebase "origin/$TARGET_BRANCH" + git push origin HEAD:"$TARGET_BRANCH" + + - name: Skip Commit if No Changes + if: steps.diff.outputs.diff == 'false' + run: echo "No changes to openapi.json, skipping commit." From a92815449dcffe5ed350b02dbd8749e185706214 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 20 May 2026 14:17:18 +0400 Subject: [PATCH 03/18] Add endpoint to retrieve all subscribe pages --- .github/workflows/front-docs.yml | 4 +- composer.json | 2 +- .../Controller/SubscribePageController.php | 78 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml index 7a6d845d..46d544dc 100644 --- a/.github/workflows/front-docs.yml +++ b/.github/workflows/front-docs.yml @@ -58,10 +58,10 @@ jobs: env: TARGET_BRANCH: ${{ needs.generate-openapi.outputs.source_branch }} steps: - - name: Checkout phplist-web-frontend Repository + - name: Checkout phpList-web-frontend Repository uses: actions/checkout@v3 with: - repository: phplist/phplist-web-frontend + repository: phpList/web-frontend token: ${{ secrets.PUSH_WEB_FRONTEND }} fetch-depth: 0 diff --git a/composer.json b/composer.json index 92598e82..477d302b 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", diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index ef7a59c3..1959dd82 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -6,11 +6,13 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; 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\Subscription\Request\SubscribePageDataRequest; use PhpList\RestBundle\Subscription\Request\SubscribePageRequest; @@ -30,10 +32,86 @@ public function __construct( private readonly SubscribePageManager $subscribePageManager, private readonly SubscribePageNormalizer $normalizer, private readonly EntityManagerInterface $entityManager, + private readonly PaginatedDataProvider $paginatedProvider, ) { parent::__construct($authentication, $validator); } + #[Route('/', name: 'get_all', methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribe-pages', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Get subscribe pages list', + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'after_id', + description: 'Last id (starting from 0)', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + ), + new OA\Parameter( + name: 'limit', + description: 'Number of results per page', + 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: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscribePage') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' + ) + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function getPages(Request $request): JsonResponse + { + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.'); + } + + return $this->json( + $this->paginatedProvider->getPaginatedList( + request: $request, + normalizer: $this->normalizer, + className: SubscribePage::class, + filter: new PaginatedFilter(), + ), + Response::HTTP_OK + ); + } + #[Route('/{id}', name: 'get', requirements: ['id' => '\\d+'], methods: ['GET'])] #[OA\Get( path: '/api/v2/subscribe-pages/{id}', From 112abce81e51fe17fb0744511684e2d3cd603410 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 23 May 2026 16:36:07 +0400 Subject: [PATCH 04/18] Developer --- src/PhpListRestBundle.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpListRestBundle.php b/src/PhpListRestBundle.php index a856c869..dd20cbf4 100644 --- a/src/PhpListRestBundle.php +++ b/src/PhpListRestBundle.php @@ -18,7 +18,7 @@ description: 'This is the OpenAPI documentation for phpList API.', title: 'phpList API Documentation', contact: new OA\Contact( - email: 'support@phplist.com' + email: 'tatevik@phplist.com' ), license: new OA\License( name: 'AGPL-3.0-or-later', From f03fe5578be020c4633dd9d3b2559d32b9eeb082 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 May 2026 15:50:22 +0400 Subject: [PATCH 05/18] Add support for subscribe page data management and validation --- .../Controller/SubscribePageController.php | 204 ++++-------------- .../Request/SubscribePageRequest.php | 55 +++++ .../SubscribePageDataNormalizer.php | 42 ++++ .../Serializer/SubscribePageNormalizer.php | 9 + .../SubscribePageControllerTest.php | 135 +++--------- .../SubscribePageNormalizerTest.php | 12 +- 6 files changed, 181 insertions(+), 276 deletions(-) create mode 100644 src/Subscription/Serializer/SubscribePageDataNormalizer.php diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 1959dd82..61ce70a9 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -14,7 +14,6 @@ use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; -use PhpList\RestBundle\Subscription\Request\SubscribePageDataRequest; use PhpList\RestBundle\Subscription\Request\SubscribePageRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -152,16 +151,12 @@ className: SubscribePage::class, ), ] )] - public function getPage( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.'); - } + public function getPage(Request $request): JsonResponse + { + $admin = $this->authentication->authenticateByApiKey($request); + $page = $this->subscribePageManager->findPage(id: (int) $request->get('id')); - if (!$page) { + if (!$page || ($page->isActive() === false && $admin === null)) { throw $this->createNotFoundException('Subscribe page not found'); } @@ -179,6 +174,18 @@ public function getPage( properties: [ new OA\Property(property: 'title', type: 'string'), new OA\Property(property: 'active', type: 'boolean', nullable: true), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string'), + ], + type: 'object' + ), + nullable: true + ), ] ) ), @@ -221,6 +228,10 @@ public function createPage(Request $request): JsonResponse $createRequest = $this->validator->validate($request, SubscribePageRequest::class); $page = $this->subscribePageManager->createPage($createRequest->title, $createRequest->active, $admin); + if ($createRequest->hasData()) { + $this->entityManager->flush(); + $this->subscribePageManager->syncPageData($createRequest->getDataMap(), $page); + } $this->entityManager->flush(); return $this->json($this->normalizer->normalize($page), Response::HTTP_CREATED); @@ -237,6 +248,18 @@ public function createPage(Request $request): JsonResponse properties: [ new OA\Property(property: 'title', type: 'string', nullable: true), new OA\Property(property: 'active', type: 'boolean', nullable: true), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string'), + ], + type: 'object' + ), + nullable: true + ), ] ) ), @@ -297,6 +320,9 @@ public function updatePage( active: $updateRequest->active, owner: $admin, ); + if ($updateRequest->hasData()) { + $this->subscribePageManager->syncPageData(data: $updateRequest->getDataMap(), page: $page); + } $this->entityManager->flush(); return $this->json($this->normalizer->normalize($updated), Response::HTTP_OK); @@ -356,162 +382,4 @@ public function deletePage( return $this->json(null, Response::HTTP_NO_CONTENT); } - - #[Route('/{id}/data', name: 'get_data', requirements: ['id' => '\\d+'], methods: ['GET'])] - #[OA\Get( - path: '/api/v2/subscribe-pages/{id}/data', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Get subscribe page data', - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'id', type: 'integer'), - new OA\Property(property: 'name', type: 'string'), - new OA\Property(property: 'data', type: 'string', nullable: true), - ], - type: 'object' - ) - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ), - new OA\Response( - response: 404, - description: 'Not Found', - content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') - ) - ] - )] - public function getPageData( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to view subscribe page data.'); - } - - if (!$page) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - $data = $this->subscribePageManager->getPageData($page); - - $json = array_map(static function ($item) { - return [ - 'id' => $item->getId(), - 'name' => $item->getName(), - 'data' => $item->getData(), - ]; - }, $data); - - return $this->json($json, Response::HTTP_OK); - } - - #[Route('/{id}/data', name: 'set_data', requirements: ['id' => '\\d+'], methods: ['PUT'])] - #[OA\Put( - path: '/api/v2/subscribe-pages/{id}/data', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Set subscribe page data item', - requestBody: new OA\RequestBody( - required: true, - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'name', type: 'string'), - new OA\Property(property: 'value', type: 'string', nullable: true), - ] - ) - ), - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'id', type: 'integer'), - new OA\Property(property: 'name', type: 'string'), - new OA\Property(property: 'data', type: 'string', nullable: true), - ], - type: 'object' - ) - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ), - new OA\Response( - response: 404, - description: 'Not Found', - content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') - ) - ] - )] - public function setPageData( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to update subscribe page data.'); - } - - if (!$page) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - /** @var SubscribePageDataRequest $createRequest */ - $createRequest = $this->validator->validate($request, SubscribePageDataRequest::class); - - $item = $this->subscribePageManager->setPageData($page, $createRequest->name, $createRequest->value); - $this->entityManager->flush(); - - return $this->json([ - 'id' => $item->getId(), - 'name' => $item->getName(), - 'data' => $item->getData(), - ], Response::HTTP_OK); - } } diff --git a/src/Subscription/Request/SubscribePageRequest.php b/src/Subscription/Request/SubscribePageRequest.php index 16f3eee5..31447f15 100644 --- a/src/Subscription/Request/SubscribePageRequest.php +++ b/src/Subscription/Request/SubscribePageRequest.php @@ -16,6 +16,61 @@ class SubscribePageRequest implements RequestInterface #[Assert\Type(type: 'bool')] public bool $active = false; + /** + * @var array|null + */ + #[Assert\Type(type: 'array')] + #[Assert\All(constraints: [ + new Assert\Collection( + fields: [ + 'key' => new Assert\Required([ + new Assert\NotBlank(), + new Assert\Type(type: 'string'), + ]), + 'value' => new Assert\Required([ + new Assert\Type(type: 'string'), + ]), + ], + allowExtraFields: false, + allowMissingFields: false + ), + ])] + private ?array $data = null; + + private bool $dataProvided = false; + + public function setData(?array $data): void + { + $this->data = $data; + $this->dataProvided = true; + } + + public function hasData(): bool + { + return $this->dataProvided; + } + + /** @return array|null */ + public function getData(): ?array + { + return $this->data; + } + + /** @return array */ + public function getDataMap(): array + { + if ($this->data === null) { + return []; + } + + $result = []; + foreach ($this->data as $item) { + $result[$item['key']] = $item['value']; + } + + return $result; + } + public function getDto(): SubscribePageRequest { return $this; diff --git a/src/Subscription/Serializer/SubscribePageDataNormalizer.php b/src/Subscription/Serializer/SubscribePageDataNormalizer.php new file mode 100644 index 00000000..c22e6f57 --- /dev/null +++ b/src/Subscription/Serializer/SubscribePageDataNormalizer.php @@ -0,0 +1,42 @@ + $object->getName(), + 'value' => $object->getData(), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof SubscribePageData; + } +} diff --git a/src/Subscription/Serializer/SubscribePageNormalizer.php b/src/Subscription/Serializer/SubscribePageNormalizer.php index d58a663b..702b6484 100644 --- a/src/Subscription/Serializer/SubscribePageNormalizer.php +++ b/src/Subscription/Serializer/SubscribePageNormalizer.php @@ -16,12 +16,18 @@ new OA\Property(property: 'title', type: 'string', example: 'Subscribe to our newsletter'), new OA\Property(property: 'active', type: 'boolean', example: true), new OA\Property(property: 'owner', ref: '#/components/schemas/Administrator'), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscribePageData') + ), ], )] class SubscribePageNormalizer implements NormalizerInterface { public function __construct( private readonly AdministratorNormalizer $adminNormalizer, + private readonly SubscribePageDataNormalizer $dataNormalizer, ) { } @@ -39,6 +45,9 @@ public function normalize($object, string $format = null, array $context = []): 'title' => $object->getTitle(), 'active' => $object->isActive(), 'owner' => $this->adminNormalizer->normalize($object->getOwner()), + 'data' => array_map(function ($data) { + return $this->dataNormalizer->normalize($data); + }, $object->getData()) ]; } diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index fa2d541a..9f432542 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -70,6 +70,9 @@ public function testCreateSubscribePageWithoutSessionReturnsForbidden(): void $payload = json_encode([ 'title' => 'new-page@example.org', 'active' => true, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Welcome'], + ], ], JSON_THROW_ON_ERROR); $this->jsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); @@ -83,6 +86,9 @@ public function testCreateSubscribePageWithSessionCreatesPage(): void $payload = json_encode([ 'title' => 'new-page@example.org', 'active' => true, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Welcome'], + ], ], JSON_THROW_ON_ERROR); $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); @@ -108,12 +114,34 @@ public function testUpdateSubscribePageWithoutSessionReturnsForbidden(): void $payload = json_encode([ 'title' => 'updated-page@example.org', 'active' => false, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Updated text'], + ], ], JSON_THROW_ON_ERROR); $this->jsonRequest('PUT', '/api/v2/subscribe-pages/1', content: $payload); $this->assertHttpForbidden(); } + public function testCreateSubscribePageWithDataMissingValueReturnsUnprocessableEntity(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + AdministratorTokenFixture::class, + SubscribePageFixture::class, + ]); + $payload = json_encode([ + 'title' => 'new-page@example.org', + 'active' => true, + 'data' => [ + ['key' => 'intro_text'], + ], + ], JSON_THROW_ON_ERROR); + + $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); + $this->assertHttpUnprocessableEntity(); + } + public function testUpdateSubscribePageWithSessionReturnsOk(): void { $this->loadFixtures([ @@ -124,6 +152,9 @@ public function testUpdateSubscribePageWithSessionReturnsOk(): void $payload = json_encode([ 'title' => 'updated-page@example.org', 'active' => false, + 'data' => [ + ['key' => 'intro_text', 'value' => 'Updated text'], + ], ], JSON_THROW_ON_ERROR); $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1', content: $payload); @@ -185,108 +216,4 @@ public function testDeleteSubscribePageWithSessionNotFound(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/subscribe-pages/9999'); $this->assertHttpNotFound(); } - - public function testGetSubscribePageDataWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); - $this->jsonRequest('GET', '/api/v2/subscribe-pages/1/data'); - $this->assertHttpForbidden(); - } - - public function testGetSubscribePageDataWithSessionReturnsArray(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - - $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/1/data'); - $this->assertHttpOkay(); - $data = $this->getDecodedJsonResponseContent(); - self::assertIsArray($data); - - if (!empty($data)) { - self::assertArrayHasKey('id', $data[0]); - self::assertArrayHasKey('name', $data[0]); - self::assertArrayHasKey('data', $data[0]); - } - } - - public function testGetSubscribePageDataWithSessionNotFound(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - - $this->authenticatedJsonRequest('GET', '/api/v2/subscribe-pages/9999/data'); - $this->assertHttpNotFound(); - } - - public function testSetSubscribePageDataWithoutSessionReturnsForbidden(): void - { - $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); - $payload = json_encode([ - 'name' => 'intro_text', - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->jsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); - $this->assertHttpForbidden(); - } - - public function testSetSubscribePageDataWithMissingNameReturnsUnprocessableEntity(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - $payload = json_encode([ - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); - $this->assertHttpUnprocessableEntity(); - } - - public function testSetSubscribePageDataWithSessionReturnsOk(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - $payload = json_encode([ - 'name' => 'intro_text', - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); - $this->assertHttpOkay(); - $data = $this->getDecodedJsonResponseContent(); - self::assertArrayHasKey('id', $data); - self::assertArrayHasKey('name', $data); - self::assertArrayHasKey('data', $data); - self::assertSame('intro_text', $data['name']); - self::assertSame('Hello world', $data['data']); - } - - public function testSetSubscribePageDataWithSessionNotFound(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - AdministratorTokenFixture::class, - SubscribePageFixture::class, - ]); - $payload = json_encode([ - 'name' => 'intro_text', - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/9999/data', content: $payload); - $this->assertHttpNotFound(); - } } diff --git a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php index 523e5904..f579b62a 100644 --- a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php +++ b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php @@ -7,6 +7,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer; +use PhpList\RestBundle\Subscription\Serializer\SubscribePageDataNormalizer; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; use PHPUnit\Framework\TestCase; use stdClass; @@ -16,7 +17,8 @@ class SubscribePageNormalizerTest extends TestCase public function testSupportsNormalization(): void { $adminNormalizer = $this->createMock(AdministratorNormalizer::class); - $normalizer = new SubscribePageNormalizer($adminNormalizer); + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $page = $this->createMock(SubscribePage::class); @@ -49,13 +51,15 @@ public function testNormalizeReturnsExpectedArray(): void $adminNormalizer = $this->createMock(AdministratorNormalizer::class); $adminNormalizer->method('normalize')->with($owner)->willReturn($adminData); - $normalizer = new SubscribePageNormalizer($adminNormalizer); + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $expected = [ 'id' => 42, 'title' => 'welcome@example.org', 'active' => true, 'owner' => $adminData, + 'data' => [], ]; $this->assertSame($expected, $normalizer->normalize($page)); @@ -64,8 +68,8 @@ public function testNormalizeReturnsExpectedArray(): void public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void { $adminNormalizer = $this->createMock(AdministratorNormalizer::class); - $normalizer = new SubscribePageNormalizer($adminNormalizer); - + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $this->assertSame([], $normalizer->normalize(new stdClass())); } } From 4a4ddfdd40da7882b629a64b22728912f4397798 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 26 May 2026 12:24:16 +0400 Subject: [PATCH 06/18] Add public page to return list data --- .../Controller/SubscribePageController.php | 2 +- .../Controller/SubscriberListController.php | 62 +++++++++++++++++++ .../SubscribePageControllerTest.php | 4 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 61ce70a9..08ca7024 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -122,7 +122,7 @@ className: SubscribePage::class, name: 'php-auth-pw', description: 'Session key obtained from login', in: 'header', - required: true, + required: false, schema: new OA\Schema(type: 'string') ), new OA\Parameter( diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index 44b6e5cf..99996c00 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -181,6 +181,68 @@ public function getList( return $this->json($this->normalizer->normalize($list), Response::HTTP_OK); } + #[Route('/{listId}/public', name: 'get_one_public', requirements: ['listId' => '\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/lists/{listId}/public', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns a single subscriber list with specified ID.', + summary: 'Gets a subscriber list.', + tags: ['lists'], + parameters: [ + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'name', type: 'string', example: 'Newsletter subscribers'), + new OA\Property( + property: 'description', + type: 'string', + example: 'Main public list', + nullable: true + ) + ] + ) + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'There is no list with that ID.' + ) + ], + type: 'object' + ) + ), + ] + )] + public function getPublicList(#[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null): JsonResponse + { + if (!$list) { + throw $this->createNotFoundException('Subscriber list not found.'); + } + + return $this->json([ + 'id' => $list->getId(), + 'name' => $list->getName(), + 'description' => $list->getDescription(), + ], Response::HTTP_OK); + } + #[Route('/{listId}', name: 'delete', requirements: ['listId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( path: '/api/v2/lists/{listId}', diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index 9f432542..cf0c7405 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -20,12 +20,12 @@ public function testControllerIsAvailableViaContainer(): void ); } - public function testGetSubscribePageWithoutSessionReturnsForbidden(): void + public function testGetSubscribePageWithoutSessionReturnsPageIfItIsActive(): void { $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); self::getClient()->request('GET', '/api/v2/subscribe-pages/1'); - $this->assertHttpForbidden(); + $this->assertHttpOkay(); } public function testGetSubscribePageWithSessionReturnsPage(): void From c3bde584e2233aab6f5634cfe3c178337dbc4e1c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 May 2026 12:20:53 +0400 Subject: [PATCH 07/18] After review 0 --- .github/workflows/front-docs.yml | 19 +++++++++++++++---- .../Controller/SubscriberListController.php | 2 ++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml index 46d544dc..855f2f5e 100644 --- a/.github/workflows/front-docs.yml +++ b/.github/workflows/front-docs.yml @@ -1,5 +1,9 @@ name: Update phplist-web-frontend OpenAPI +permissions: + contents: write # Required to push to web-frontend repo + actions: read # Required to download artifacts + on: push: branches: @@ -15,16 +19,22 @@ jobs: source_branch: ${{ steps.branch.outputs.source_branch }} steps: - name: Determine source branch + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REF: ${{ github.head_ref }} + REF_NAME: ${{ github.ref_name }} id: branch run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "source_branch=${{ github.head_ref }}" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "source_branch=$HEAD_REF" >> "$GITHUB_OUTPUT" else - echo "source_branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + echo "source_branch=$REF_NAME" >> "$GITHUB_OUTPUT" fi - name: Checkout Source Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 @@ -64,6 +74,7 @@ jobs: repository: phpList/web-frontend token: ${{ secrets.PUSH_WEB_FRONTEND }} fetch-depth: 0 + persist-credentials: false - name: Prepare target branch run: | diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index 99996c00..e151dd60 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -205,6 +205,7 @@ public function getList( properties: [ new OA\Property(property: 'id', type: 'integer', example: 1), new OA\Property(property: 'name', type: 'string', example: 'Newsletter subscribers'), + new OA\Property(property: 'list_position', type: 'integer', example: 1), new OA\Property( property: 'description', type: 'string', @@ -240,6 +241,7 @@ public function getPublicList(#[MapEntity(mapping: ['listId' => 'id'])] ?Subscri 'id' => $list->getId(), 'name' => $list->getName(), 'description' => $list->getDescription(), + 'list_position' => $list->getListPosition(), ], Response::HTTP_OK); } From 6454deace40ea6cd3b0cc7b3fcf41196f4feedf9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 May 2026 17:42:51 +0400 Subject: [PATCH 08/18] Normalize attributes key in SubscribePageData --- src/Subscription/Serializer/SubscribePageDataNormalizer.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Subscription/Serializer/SubscribePageDataNormalizer.php b/src/Subscription/Serializer/SubscribePageDataNormalizer.php index c22e6f57..eeef9f8f 100644 --- a/src/Subscription/Serializer/SubscribePageDataNormalizer.php +++ b/src/Subscription/Serializer/SubscribePageDataNormalizer.php @@ -26,6 +26,10 @@ public function normalize($object, string $format = null, array $context = []): return []; } + if ($object->getName() === 'attributes') { + $object->setData(trim(str_replace('+', ',', $object->getData()), ',')); + } + return [ 'key' => $object->getName(), 'value' => $object->getData(), From 66ecaa90904b4f662a5562983f0420d63cfcc398 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 29 May 2026 15:56:02 +0400 Subject: [PATCH 09/18] persist-credentials --- .github/workflows/front-docs.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml index 855f2f5e..0aac1038 100644 --- a/.github/workflows/front-docs.yml +++ b/.github/workflows/front-docs.yml @@ -33,8 +33,6 @@ jobs: - name: Checkout Source Repository uses: actions/checkout@v4 - with: - persist-credentials: false - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 @@ -74,7 +72,6 @@ jobs: repository: phpList/web-frontend token: ${{ secrets.PUSH_WEB_FRONTEND }} fetch-depth: 0 - persist-credentials: false - name: Prepare target branch run: | From 3b954687ccfde11a944e7f1b9634dbbda993b5ec Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 1 Jun 2026 19:54:00 +0400 Subject: [PATCH 10/18] Add: getPublicPage endpoint --- .../Controller/SubscribePageController.php | 49 ++++++++++++++- .../SubscribePagePublicNormalizer.php | 61 +++++++++++++++++++ .../SubscribePageControllerTest.php | 4 +- .../SubscribePagePublicNormalizerTest.php | 51 ++++++++++++++++ 4 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 src/Subscription/Serializer/SubscribePagePublicNormalizer.php create mode 100644 tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 08ca7024..cbb6d8c8 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -16,6 +16,7 @@ use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Subscription\Request\SubscribePageRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; +use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -153,16 +154,58 @@ className: SubscribePage::class, )] public function getPage(Request $request): JsonResponse { - $admin = $this->authentication->authenticateByApiKey($request); - $page = $this->subscribePageManager->findPage(id: (int) $request->get('id')); + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.'); + } - if (!$page || ($page->isActive() === false && $admin === null)) { + $page = $this->subscribePageManager->findPage(id: (int) $request->get('id')); + if (!$page) { throw $this->createNotFoundException('Subscribe page not found'); } return $this->json($this->normalizer->normalize($page), Response::HTTP_OK); } + #[Route('/{id}/public', name: 'get_public', requirements: ['id' => '\\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribe-pages/{id}/public', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Get public subscribe page (placeholders replaced with actual values)', + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/SubscribePagePublic'), + ), + new OA\Response( + response: 404, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function getPublicPage(Request $request, SubscribePagePublicNormalizer $normalizer): JsonResponse + { + $page = $this->subscribePageManager->findPublicPage(id: (int) $request->get('id')); + + if (!$page || $page->isActive() === false) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + return $this->json($normalizer->normalize($page), Response::HTTP_OK); + } + #[Route('', name: 'create', methods: ['POST'])] #[OA\Post( path: '/api/v2/subscribe-pages', diff --git a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php new file mode 100644 index 00000000..7862ccd8 --- /dev/null +++ b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php @@ -0,0 +1,61 @@ + $object->getId(), + 'title' => $object->getTitle(), + 'data' => array_reduce( + $object->getData(), + function (array $carry, SubscribePageData $data) { + $carry[$data->getName()] = $data->getData(); + return $carry; + }, + [] + ), + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof SubscribePage; + } +} diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index cf0c7405..9f432542 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -20,12 +20,12 @@ public function testControllerIsAvailableViaContainer(): void ); } - public function testGetSubscribePageWithoutSessionReturnsPageIfItIsActive(): void + public function testGetSubscribePageWithoutSessionReturnsForbidden(): void { $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); self::getClient()->request('GET', '/api/v2/subscribe-pages/1'); - $this->assertHttpOkay(); + $this->assertHttpForbidden(); } public function testGetSubscribePageWithSessionReturnsPage(): void diff --git a/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php new file mode 100644 index 00000000..d66fd5f6 --- /dev/null +++ b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php @@ -0,0 +1,51 @@ +createMock(SubscribePage::class); + + $this->assertTrue($normalizer->supportsNormalization($page)); + $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $owner = $this->createMock(Administrator::class); + + $page = $this->createMock(SubscribePage::class); + $page->method('getId')->willReturn(42); + $page->method('getTitle')->willReturn('welcome@example.org'); + $page->method('isActive')->willReturn(true); + $page->method('getOwner')->willReturn($owner); + + $normalizer = new SubscribePagePublicNormalizer(); + + $expected = [ + 'id' => 42, + 'title' => 'welcome@example.org', + 'data' => [], + ]; + + $this->assertSame($expected, $normalizer->normalize($page)); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $normalizer = new SubscribePagePublicNormalizer(); + $this->assertSame([], $normalizer->normalize(new stdClass())); + } +} From 2b966c2a7364db499e6aed48087f4b7901e26226 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 2 Jun 2026 14:02:26 +0400 Subject: [PATCH 11/18] Enhance SubscribePagePublicNormalizer to resolve attribute IDs using SubscriberAttributeDefinitionRepository --- .../SubscribePagePublicNormalizer.php | 33 ++++++++++++++++++- .../SubscribePagePublicNormalizerTest.php | 13 ++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php index 7862ccd8..dc95cda9 100644 --- a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php +++ b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php @@ -7,6 +7,7 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\Core\Domain\Subscription\Model\SubscribePageData; +use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; #[OA\Schema( @@ -28,6 +29,11 @@ )] class SubscribePagePublicNormalizer implements NormalizerInterface { + public function __construct( + private readonly SubscriberAttributeDefinitionRepository $attributeDefinitionRepository, + ) { + } + /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ @@ -43,7 +49,13 @@ public function normalize($object, string $format = null, array $context = []): 'data' => array_reduce( $object->getData(), function (array $carry, SubscribePageData $data) { - $carry[$data->getName()] = $data->getData(); + $value = $data->getData(); + if ($data->getName() === 'attributes') { + $ids = array_filter(explode('+', $data->getData())); + $value = $this->getAttributeDefinitions($ids); + } + $carry[$data->getName()] = $value; + return $carry; }, [] @@ -51,6 +63,25 @@ function (array $carry, SubscribePageData $data) { ]; } + private function getAttributeDefinitions(array $ids): array + { + $attributeDefinitions = $this->attributeDefinitionRepository->getByIds($ids); + $result = []; + foreach ($attributeDefinitions as $attributeDefinition) { + $result[] = [ + 'id' => $attributeDefinition->getId(), + 'name' => $attributeDefinition->getName(), + 'type' => $attributeDefinition->getType()->value, + 'required' => $attributeDefinition->isRequired(), + 'default_value' => $attributeDefinition->getDefaultValue(), + 'list_order' => $attributeDefinition->getListOrder(), + 'options' => $attributeDefinition->getOptions(), + ]; + } + + return $result; + } + /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ diff --git a/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php index d66fd5f6..d8647779 100644 --- a/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php +++ b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Model\SubscribePage; +use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; use PHPUnit\Framework\TestCase; use stdClass; @@ -14,7 +15,9 @@ class SubscribePagePublicNormalizerTest extends TestCase { public function testSupportsNormalization(): void { - $normalizer = new SubscribePagePublicNormalizer(); + $normalizer = new SubscribePagePublicNormalizer( + $this->createMock(SubscriberAttributeDefinitionRepository::class) + ); $page = $this->createMock(SubscribePage::class); @@ -32,7 +35,9 @@ public function testNormalizeReturnsExpectedArray(): void $page->method('isActive')->willReturn(true); $page->method('getOwner')->willReturn($owner); - $normalizer = new SubscribePagePublicNormalizer(); + $normalizer = new SubscribePagePublicNormalizer( + $this->createMock(SubscriberAttributeDefinitionRepository::class) + ); $expected = [ 'id' => 42, @@ -45,7 +50,9 @@ public function testNormalizeReturnsExpectedArray(): void public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void { - $normalizer = new SubscribePagePublicNormalizer(); + $normalizer = new SubscribePagePublicNormalizer( + $this->createMock(SubscriberAttributeDefinitionRepository::class) + ); $this->assertSame([], $normalizer->normalize(new stdClass())); } } From 3a57f7b8a61a9d91848c2e42d95024963b5abea8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 3 Jun 2026 13:43:10 +0400 Subject: [PATCH 12/18] Add PublicSubscriptionRequest class and update subscribe method in SubscriptionController --- config/services/services.yml | 4 + config/services/validators.yml | 5 + src/Common/Validator/RequestValidator.php | 6 +- .../Controller/SubscribePageController.php | 106 +++++++++ .../Controller/SubscriptionController.php | 13 +- .../Request/PublicSubscriptionRequest.php | 91 ++++++++ ...ublicSubscriptionAttributeRuleProvider.php | 204 ++++++++++++++++++ .../Constraint/ValidPublicSubscription.php | 28 +++ .../ValidPublicSubscriptionValidator.php | 197 +++++++++++++++++ .../SubscribePageControllerTest.php | 42 ++++ ...cSubscriptionAttributeRuleProviderTest.php | 78 +++++++ .../ValidPublicSubscriptionValidatorTest.php | 128 +++++++++++ 12 files changed, 891 insertions(+), 11 deletions(-) create mode 100644 src/Subscription/Request/PublicSubscriptionRequest.php create mode 100644 src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php create mode 100644 src/Subscription/Validator/Constraint/ValidPublicSubscription.php create mode 100644 src/Subscription/Validator/Constraint/ValidPublicSubscriptionValidator.php create mode 100644 tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php create mode 100644 tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php diff --git a/config/services/services.yml b/config/services/services.yml index 17127575..b76fc913 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -3,6 +3,10 @@ services: autowire: true autoconfigure: true + PhpList\RestBundle\Subscription\Service\PublicSubscriptionAttributeRuleProvider: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\ForwardingGuard: autowire: true autoconfigure: true diff --git a/config/services/validators.yml b/config/services/validators.yml index 8d68037f..eabc05de 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -42,6 +42,11 @@ services: autoconfigure: true tags: [ 'validator.constraint_validator' ] + PhpList\RestBundle\Subscription\Validator\Constraint\ValidPublicSubscriptionValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator: autowire: true autoconfigure: true diff --git a/src/Common/Validator/RequestValidator.php b/src/Common/Validator/RequestValidator.php index ddd648ad..91694d2f 100644 --- a/src/Common/Validator/RequestValidator.php +++ b/src/Common/Validator/RequestValidator.php @@ -20,7 +20,7 @@ public function __construct( ) { } - public function validate(Request $request, string $dtoClass): RequestInterface + public function validate(Request $request, string $dtoClass, ?callable $beforeValidation = null): RequestInterface { try { $content = $request->getContent(); @@ -53,6 +53,10 @@ public function validate(Request $request, string $dtoClass): RequestInterface ); } + if ($beforeValidation !== null) { + $beforeValidation($dto); + } + return $this->validateDto($dto); } diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index cbb6d8c8..2b64c1fc 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -9,14 +9,19 @@ use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\SubscribePage; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; 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\Subscription\Request\PublicSubscriptionRequest; use PhpList\RestBundle\Subscription\Request\SubscribePageRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; +use PhpList\RestBundle\Subscription\Serializer\SubscriptionNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -33,6 +38,9 @@ public function __construct( private readonly SubscribePageNormalizer $normalizer, private readonly EntityManagerInterface $entityManager, private readonly PaginatedDataProvider $paginatedProvider, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriptionNormalizer $subscriptionNormalizer, + private readonly SubscriberAttributeManager $subscriberAttributeManager, ) { parent::__construct($authentication, $validator); } @@ -425,4 +433,102 @@ public function deletePage( return $this->json(null, Response::HTTP_NO_CONTENT); } + + #[Route( + '/{id}/lists/{listId}/subscribers', + name: 'subscribe', + requirements: ['listId' => '\d+', 'id' => '\d+'], + methods: ['POST'] + )] + #[OA\Post( + path: '/api/v2/subscribe-pages/{id}/lists/{listId}/subscribers', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.' . + 'Subscribe subscriber to a list from subscribe page.', + summary: 'Create subscription', + requestBody: new OA\RequestBody( + description: '', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/PublicSubscriptionRequest') + ), + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'listId', + description: 'List ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Subscription') + ) + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function subscribe( + Request $request, + int $id, + #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, + ): JsonResponse { + $page = $this->subscribePageManager->findPublicPage(id: $id); + if (!$list || !$page) { + throw $this->createNotFoundException('Subscriber list or subscribe page not found.'); + } + + /** @var PublicSubscriptionRequest $subscriptionRequest */ + $subscriptionRequest = $this->validator->validate( + request: $request, + dtoClass: PublicSubscriptionRequest::class, + beforeValidation: static function (PublicSubscriptionRequest $dto) use ($page): void { + $dto->setSubscribePage($page); + } + ); + $subscriberEmail = $subscriptionRequest->email; + $subscriptions = $this->subscriptionManager->createSubscriptions( + subscriberList: $list, + emails: [$subscriberEmail], + autoConfirm: false, + ); + $this->entityManager->flush(); + + if ($subscriptionRequest->attributes !== []) { + $this->subscriberAttributeManager->processAttributes( + subscriber: $subscriptions[0]->getSubscriber(), + attributeData: $subscriptionRequest->attributes + ); + } + $this->entityManager->flush(); + + $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); + + return $this->json($normalized, Response::HTTP_CREATED); + } } diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 2a038f96..b67db97d 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -28,21 +28,14 @@ #[Route('/lists', name: 'subscription_')] class SubscriptionController extends BaseController { - private SubscriptionManager $subscriptionManager; - private SubscriptionNormalizer $subscriptionNormalizer; - private EntityManagerInterface $entityManager; - public function __construct( Authentication $authentication, RequestValidator $validator, - SubscriptionManager $subscriptionManager, - SubscriptionNormalizer $subscriptionNormalizer, - EntityManagerInterface $entityManager, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriptionNormalizer $subscriptionNormalizer, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); - $this->subscriptionManager = $subscriptionManager; - $this->subscriptionNormalizer = $subscriptionNormalizer; - $this->entityManager = $entityManager; } #[Route('/{listId}/subscribers', name: 'create', requirements: ['listId' => '\d+'], methods: ['POST'])] diff --git a/src/Subscription/Request/PublicSubscriptionRequest.php b/src/Subscription/Request/PublicSubscriptionRequest.php new file mode 100644 index 00000000..4bc62fb2 --- /dev/null +++ b/src/Subscription/Request/PublicSubscriptionRequest.php @@ -0,0 +1,91 @@ + 'John', + 'lastname' => 'Grigoryan', + 'country' => 'Armenia', + ], + additionalProperties: true + ), + ] +)] +#[ValidPublicSubscription] +class PublicSubscriptionRequest implements RequestInterface +{ + #[Assert\NotBlank] + #[Assert\Email] + public ?string $email = null; + + #[Assert\NotBlank] + #[Assert\Email] + #[Assert\EqualTo( + propertyPath: 'email', + message: 'Email addresses do not match.' + )] + public ?string $confirmEmail = null; + + /** + * Key/value pairs matching the subscribe page attributes. + * + * Example: + * [ + * 'firstname' => 'John', + * 'lastname' => 'Doe', + * ] + */ + #[Assert\Type('array')] + public array $attributes = []; + + #[Ignore] + private ?SubscribePage $subscribePage = null; + + public function getDto(): self + { + if ($this->email !== null) { + $this->email = trim($this->email); + } + + return $this; + } + + public function setSubscribePage(SubscribePage $subscribePage): self + { + $this->subscribePage = $subscribePage; + + return $this; + } + + public function getSubscribePage(): ?SubscribePage + { + return $this->subscribePage; + } +} diff --git a/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php b/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php new file mode 100644 index 00000000..150b06d2 --- /dev/null +++ b/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php @@ -0,0 +1,204 @@ +, + * max_length:int|null + * }> + */ + public function getRules(SubscribePage $page): array + { + $pageData = $this->toMap($page); + $selectedIds = $this->parseSelectedAttributeIds($pageData['attributes'] ?? null); + $hasPageData = $pageData !== []; + + $definitions = $selectedIds !== [] + ? $this->attributeDefinitionRepository->getByIds($selectedIds) + : ($hasPageData ? [] : $this->attributeDefinitionRepository->findBy([])); + + $legacyOverrides = $this->extractLegacyOverrides($pageData); + $modernOverrides = $this->extractModernOverrides($pageData); + + $rules = []; + foreach ($definitions as $definition) { + $id = $definition->getId(); + if ($id === null) { + continue; + } + + $override = $modernOverrides[$id] ?? $legacyOverrides[$id] ?? []; + $shouldUse = !array_key_exists('use', $override) || (bool)$override['use']; + if (!$shouldUse) { + continue; + } + + $key = mb_strtolower(trim($definition->getName())); + if ($key === '') { + continue; + } + + $rules[$key] = [ + 'id' => $id, + 'key' => $key, + 'type' => $definition->getType(), + 'required' => array_key_exists('required', $override) + ? (bool) $override['required'] + : (bool) $definition->isRequired(), + 'allowed' => $this->allowedOptions($definition), + 'max_length' => $this->resolveMaxLength($override), + ]; + } + + return $rules; + } + + /** + * @return array + */ + private function toMap(SubscribePage $page): array + { + $map = []; + foreach ($page->getData() as $item) { + if (!$item instanceof SubscribePageData) { + continue; + } + $map[$item->getName()] = $item->getData(); + } + + return $map; + } + + /** + * @return int[] + */ + private function parseSelectedAttributeIds(?string $raw): array + { + if ($raw === null || trim($raw) === '') { + return []; + } + + $ids = array_filter(array_map('trim', explode('+', $raw)), static fn (string $id): bool => $id !== ''); + return array_values(array_unique(array_map('intval', $ids))); + } + + /** + * @param array $pageData + * @return array + */ + private function extractLegacyOverrides(array $pageData): array + { + $result = []; + foreach ($pageData as $key => $value) { + if (!preg_match('/^attribute(\d{1,})$/', $key, $matches)) { + continue; + } + + $id = (int) $matches[1]; + $parts = explode('###', (string) $value); + // phpList 3 structure: order###default###use###required + if (isset($parts[2])) { + $result[$id]['use'] = $this->isTruthy($parts[2]); + } + if (isset($parts[3])) { + $result[$id]['required'] = $this->isTruthy($parts[3]); + } + if (isset($parts[4]) && is_numeric($parts[4])) { + $result[$id]['max_length'] = (int) $parts[4]; + } + } + + return $result; + } + + /** + * @param array $pageData + * @return array + */ + private function extractModernOverrides(array $pageData): array + { + $result = []; + + foreach ($pageData as $key => $value) { + if (!preg_match('/^attribute_(\d+)_(use|required|maxlength)$/', $key, $matches)) { + continue; + } + + $id = (int) $matches[1]; + $suffix = $matches[2]; + + if ($suffix === 'maxlength') { + if (is_numeric($value)) { + $result[$id]['max_length'] = (int) $value; + } + continue; + } + + $result[$id][$suffix] = $this->isTruthy($value); + } + + return $result; + } + + private function isTruthy(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int) $value === 1; + } + + return in_array(mb_strtolower(trim((string) $value)), ['true', 'yes', 'on'], true); + } + + /** + * @return array + */ + private function allowedOptions(SubscriberAttributeDefinition $definition): array + { + $allowed = []; + foreach ($definition->getOptions() as $option) { + if ($option->id !== null) { + $allowed[(string) $option->id] = true; + } + } + + return $allowed; + } + + /** + * @param array{use?:bool,required?:bool,max_length?:int} $override + */ + private function resolveMaxLength(array $override): ?int + { + if (!array_key_exists('max_length', $override)) { + return null; + } + + $max = (int) $override['max_length']; + return $max > 0 ? $max : null; + } +} + diff --git a/src/Subscription/Validator/Constraint/ValidPublicSubscription.php b/src/Subscription/Validator/Constraint/ValidPublicSubscription.php new file mode 100644 index 00000000..7deea36a --- /dev/null +++ b/src/Subscription/Validator/Constraint/ValidPublicSubscription.php @@ -0,0 +1,28 @@ +getSubscribePage(); + if ($page === null) { + return; + } + + $rules = $this->ruleProvider->getRules($page); + $submitted = is_array($value->attributes) ? $value->attributes : []; + $submittedByKey = []; + foreach ($submitted as $rawKey => $rawValue) { + $key = mb_strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + $submittedByKey[$key] = ['path' => (string) $rawKey, 'value' => $rawValue]; + } + + if ($constraint->rejectUnknownAttributes) { + foreach ($submittedByKey as $key => $entry) { + if (!isset($rules[$key])) { + $this->context->buildViolation($constraint->unknownAttributeMessage) + ->atPath('attributes.' . $entry['path']) + ->addViolation(); + } + } + } + + foreach ($rules as $key => $rule) { + $submittedEntry = $submittedByKey[$key] ?? null; + $submittedValue = $submittedEntry['value'] ?? null; + $pathKey = $submittedEntry['path'] ?? $rule['key']; + + if ($rule['required'] && $this->isEmptyValue($submittedValue, $rule['type'])) { + $this->context->buildViolation($constraint->requiredAttributeMessage) + ->atPath('attributes.' . $pathKey) + ->addViolation(); + continue; + } + + if ($this->isEmptyValue($submittedValue, $rule['type'])) { + continue; + } + + if (!$this->isValidTypeValue($submittedValue, $rule)) { + $this->context->buildViolation($constraint->invalidValueMessage) + ->atPath('attributes.' . $pathKey) + ->addViolation(); + continue; + } + + if ($rule['max_length'] !== null) { + $asString = trim((string) $submittedValue); + if (mb_strlen($asString) > $rule['max_length']) { + $this->context->buildViolation($constraint->invalidValueMessage) + ->atPath('attributes.' . $pathKey) + ->addViolation(); + } + } + } + } + + private function isEmptyValue(mixed $value, ?AttributeTypeEnum $type): bool + { + if ($type === AttributeTypeEnum::CheckboxGroup) { + return !is_array($value) || $value === []; + } + + if ($type === AttributeTypeEnum::Checkbox) { + return !$this->toBool($value); + } + + if (is_array($value)) { + return $value === []; + } + + return trim((string) $value) === ''; + } + + /** + * @param array{ + * type:AttributeTypeEnum|null, + * allowed:array + * } $rule + */ + private function isValidTypeValue(mixed $value, array $rule): bool + { + $type = $rule['type']; + $allowed = $rule['allowed']; + + if ($type === AttributeTypeEnum::Checkbox) { + return is_bool($value) + || is_numeric($value) + || in_array(mb_strtolower(trim((string) $value)), ['on', 'off', 'true', 'false', 'yes', 'no'], true); + } + + if ($type === AttributeTypeEnum::CheckboxGroup) { + if (!is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (!isset($allowed[(string) $item])) { + return false; + } + } + + return true; + } + + if ($type === AttributeTypeEnum::Select || $type === AttributeTypeEnum::Radio) { + return isset($allowed[(string) $value]); + } + + if ($type === AttributeTypeEnum::Date) { + return $this->isValidDateValue($value); + } + + if ($type === AttributeTypeEnum::Number) { + return is_numeric($value); + } + + if (is_array($value) || is_object($value)) { + return false; + } + + return true; + } + + private function isValidDateValue(mixed $value): bool + { + if (is_array($value)) { + $year = $value['year'] ?? $value['yyyy'] ?? null; + $month = $value['month'] ?? $value['mm'] ?? null; + $day = $value['day'] ?? $value['dd'] ?? null; + + if (!is_numeric($year) || !is_numeric($month) || !is_numeric($day)) { + return false; + } + + return checkdate((int) $month, (int) $day, (int) $year); + } + + $stringValue = trim((string) $value); + if ($stringValue === '') { + return false; + } + + $date = \DateTimeImmutable::createFromFormat('Y-m-d', $stringValue); + if ($date !== false && $date->format('Y-m-d') === $stringValue) { + return true; + } + + return strtotime($stringValue) !== false; + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int) $value === 1; + } + + return in_array(mb_strtolower(trim((string) $value)), ['1', 'on', 'true', 'yes'], true); + } +} diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index 9f432542..b6879e26 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -4,11 +4,16 @@ namespace PhpList\RestBundle\Tests\Integration\Subscription\Controller; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; +use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; use PhpList\RestBundle\Subscription\Controller\SubscribePageController; use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController; use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture; use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorTokenFixture; use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscribePageFixture; +use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberAttributeDefinitionFixture; +use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberListFixture; class SubscribePageControllerTest extends AbstractTestController { @@ -216,4 +221,41 @@ public function testDeleteSubscribePageWithSessionNotFound(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/subscribe-pages/9999'); $this->assertHttpNotFound(); } + + public function testPublicSubscribeCreatesSubscriptionAndAttributes(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + SubscribePageFixture::class, + SubscriberListFixture::class, + SubscriberAttributeDefinitionFixture::class, + ]); + + $payload = json_encode([ + 'email' => 'public@example.com', + 'confirmEmail' => 'public@example.com', + 'attributes' => [ + 'Country' => 'Armenia', + ], + ]); + + $this->jsonRequest('POST', '/api/v2/subscribe-pages/1/lists/1/subscribers', [], [], [], $payload); + $this->assertHttpCreated(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('public@example.com', $response[0]['subscriber']['email'] ?? null); + + $subscriber = $this->entityManager?->getRepository(Subscriber::class)->findOneBy(['email' => 'public@example.com']); + self::assertInstanceOf(Subscriber::class, $subscriber); + + $definition = $this->entityManager?->getRepository(SubscriberAttributeDefinition::class)->findOneBy(['name' => 'Country']); + self::assertInstanceOf(SubscriberAttributeDefinition::class, $definition); + + $value = $this->entityManager?->getRepository(SubscriberAttributeValue::class)->findOneBy([ + 'subscriber' => $subscriber, + 'attributeDefinition' => $definition, + ]); + self::assertInstanceOf(SubscriberAttributeValue::class, $value); + self::assertSame('Armenia', $value->getValue()); + } } diff --git a/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php b/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php new file mode 100644 index 00000000..a0ea7060 --- /dev/null +++ b/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php @@ -0,0 +1,78 @@ +repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); + $this->provider = new PublicSubscriptionAttributeRuleProvider($this->repository); + } + + public function testBuildsRulesWithModernOverridesAndAllowedOptions(): void + { + $page = (new SubscribePage())->setData([ + (new SubscribePageData())->setId(1)->setName('attributes')->setData('1'), + (new SubscribePageData())->setId(1)->setName('attribute_1_required')->setData('1'), + (new SubscribePageData())->setId(1)->setName('attribute_1_maxlength')->setData('5'), + ]); + + $definition = $this->createMock(SubscriberAttributeDefinition::class); + $definition->method('getId')->willReturn(1); + $definition->method('getName')->willReturn('Country'); + $definition->method('getType')->willReturn(AttributeTypeEnum::Select); + $definition->method('isRequired')->willReturn(false); + $definition->method('getOptions')->willReturn([ + new DynamicListAttrDto(10, 'Armenia', 1), + new DynamicListAttrDto(11, 'France', 2), + ]); + + $this->repository->expects($this->once())->method('getByIds')->with([1])->willReturn([$definition]); + + $rules = $this->provider->getRules($page); + + $this->assertArrayHasKey('country', $rules); + $this->assertTrue($rules['country']['required']); + $this->assertSame(5, $rules['country']['max_length']); + $this->assertArrayHasKey('10', $rules['country']['allowed']); + $this->assertArrayHasKey('11', $rules['country']['allowed']); + } + + public function testExcludesAttributesDisabledInLegacyOverride(): void + { + $page = (new SubscribePage())->setData([ + (new SubscribePageData())->setId(1)->setName('attributes')->setData('2'), + (new SubscribePageData())->setId(1)->setName('attribute002')->setData('1###default###0###1'), + ]); + + $definition = $this->createMock(SubscriberAttributeDefinition::class); + $definition->method('getId')->willReturn(2); + $definition->method('getName')->willReturn('State'); + $definition->method('getType')->willReturn(AttributeTypeEnum::TextLine); + $definition->method('isRequired')->willReturn(true); + $definition->method('getOptions')->willReturn([]); + + $this->repository->expects($this->once())->method('getByIds')->with([2])->willReturn([$definition]); + + $rules = $this->provider->getRules($page); + + $this->assertSame([], $rules); + } +} + diff --git a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php new file mode 100644 index 00000000..8c980ad3 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php @@ -0,0 +1,128 @@ +ruleProvider = $this->createMock(PublicSubscriptionAttributeRuleProvider::class); + $this->context = $this->createMock(ExecutionContextInterface::class); + + $this->validator = new ValidPublicSubscriptionValidator($this->ruleProvider); + $this->validator->initialize($this->context); + } + + public function testSkipsWhenSubscribePageIsMissing(): void + { + $request = new PublicSubscriptionRequest(); + $request->email = 'test@example.com'; + $request->attributes = ['country' => '1']; + + $this->ruleProvider->expects($this->never())->method('getRules'); + $this->context->expects($this->never())->method('buildViolation'); + + $this->validator->validate($request, new ValidPublicSubscription()); + $this->assertTrue(true); + } + + public function testAddsViolationsForUnknownAndRequiredAttributes(): void + { + $request = new PublicSubscriptionRequest(); + $request->email = 'test@example.com'; + $request->attributes = ['unknown' => 'x']; + $request->setSubscribePage(new SubscribePage()); + + $this->ruleProvider->expects($this->once()) + ->method('getRules') + ->willReturn([ + 'country' => [ + 'id' => 1, + 'key' => 'country', + 'type' => AttributeTypeEnum::TextLine, + 'required' => true, + 'allowed' => [], + 'max_length' => null, + ], + ]); + + $messages = []; + $paths = []; + $violations = 0; + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->method('atPath') + ->willReturnCallback(function (string $path) use (&$paths, $builder) { + $paths[] = $path; + + return $builder; + }); + $builder->method('addViolation') + ->willReturnCallback(function () use (&$violations): void { + ++$violations; + }); + + $this->context->method('buildViolation') + ->willReturnCallback(function (string $message) use (&$messages, $builder) { + $messages[] = $message; + + return $builder; + }); + + $this->validator->validate($request, new ValidPublicSubscription()); + + $this->assertSame(['Unknown attribute.', 'This attribute is required.'], $messages); + $this->assertSame(['attributes.unknown', 'attributes.country'], $paths); + $this->assertSame(2, $violations); + } + + public function testRejectsInvalidCheckboxGroupOption(): void + { + $request = new PublicSubscriptionRequest(); + $request->email = 'test@example.com'; + $request->attributes = ['country' => ['1', '99']]; + $request->setSubscribePage(new SubscribePage()); + + $this->ruleProvider->expects($this->once()) + ->method('getRules') + ->willReturn([ + 'country' => [ + 'id' => 1, + 'key' => 'country', + 'type' => AttributeTypeEnum::CheckboxGroup, + 'required' => false, + 'allowed' => ['1' => true, '2' => true], + 'max_length' => null, + ], + ]); + + $builder = $this->createMock(ConstraintViolationBuilderInterface::class); + $builder->expects($this->once())->method('atPath')->with('attributes.country')->willReturnSelf(); + $builder->expects($this->once())->method('addViolation'); + + $this->context->expects($this->once()) + ->method('buildViolation') + ->with('Invalid value.') + ->willReturn($builder); + + $this->validator->validate($request, new ValidPublicSubscription()); + } +} + From cde97d579a88568b9f654c1b797220f56525e534 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 3 Jun 2026 16:34:40 +0400 Subject: [PATCH 13/18] Add PublicSubscriptionRequest class and update subscribe method in SubscriptionController --- config/services/validators.yml | 5 + .../Controller/SubscribePageController.php | 258 ++++-------------- .../SubscribePagePublicController.php | 162 +++++++++++ .../Controller/SubscriberListController.php | 64 ----- .../Request/PublicSubscriptionRequest.php | 12 +- .../SubscribePagePublicNormalizer.php | 24 +- ...ublicSubscriptionAttributeRuleProvider.php | 134 +-------- .../Validator/Constraint/ListExistsPublic.php | 22 ++ .../Constraint/ListExistsPublicValidator.php | 35 +++ .../Constraint/ListExistsValidator.php | 1 - .../Constraint/ValidPublicSubscription.php | 1 - .../ValidPublicSubscriptionValidator.php | 172 ++++++------ .../SubscribePageControllerTest.php | 15 +- .../SubscribePagePublicNormalizerTest.php | 30 +- ...cSubscriptionAttributeRuleProviderTest.php | 54 ++-- .../ValidPublicSubscriptionValidatorTest.php | 9 +- 16 files changed, 471 insertions(+), 527 deletions(-) create mode 100644 src/Subscription/Controller/SubscribePagePublicController.php create mode 100644 src/Subscription/Validator/Constraint/ListExistsPublic.php create mode 100644 src/Subscription/Validator/Constraint/ListExistsPublicValidator.php diff --git a/config/services/validators.yml b/config/services/validators.yml index eabc05de..2c45805d 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -42,6 +42,11 @@ services: autoconfigure: true tags: [ 'validator.constraint_validator' ] + PhpList\RestBundle\Subscription\Validator\Constraint\ListExistsPublicValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + PhpList\RestBundle\Subscription\Validator\Constraint\ValidPublicSubscriptionValidator: autowire: true autoconfigure: true diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 2b64c1fc..59fa1aa9 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -9,19 +9,13 @@ use PhpList\Core\Domain\Common\Model\Filter\PaginatedFilter; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Subscription\Model\SubscribePage; -use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; 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\Subscription\Request\PublicSubscriptionRequest; use PhpList\RestBundle\Subscription\Request\SubscribePageRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; -use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; -use PhpList\RestBundle\Subscription\Serializer\SubscriptionNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -38,9 +32,6 @@ public function __construct( private readonly SubscribePageNormalizer $normalizer, private readonly EntityManagerInterface $entityManager, private readonly PaginatedDataProvider $paginatedProvider, - private readonly SubscriptionManager $subscriptionManager, - private readonly SubscriptionNormalizer $subscriptionNormalizer, - private readonly SubscriberAttributeManager $subscriberAttributeManager, ) { parent::__construct($authentication, $validator); } @@ -120,101 +111,7 @@ className: SubscribePage::class, ); } - #[Route('/{id}', name: 'get', requirements: ['id' => '\\d+'], methods: ['GET'])] - #[OA\Get( - path: '/api/v2/subscribe-pages/{id}', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Get subscribe page', - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: false, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage'), - ), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ), - new OA\Response( - response: 404, - description: 'Not Found', - content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') - ), - ] - )] - public function getPage(Request $request): JsonResponse - { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.'); - } - - $page = $this->subscribePageManager->findPage(id: (int) $request->get('id')); - if (!$page) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - return $this->json($this->normalizer->normalize($page), Response::HTTP_OK); - } - - #[Route('/{id}/public', name: 'get_public', requirements: ['id' => '\\d+'], methods: ['GET'])] - #[OA\Get( - path: '/api/v2/subscribe-pages/{id}/public', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Get public subscribe page (placeholders replaced with actual values)', - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ) - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent(ref: '#/components/schemas/SubscribePagePublic'), - ), - new OA\Response( - response: 404, - description: 'Not Found', - content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') - ), - ] - )] - public function getPublicPage(Request $request, SubscribePagePublicNormalizer $normalizer): JsonResponse - { - $page = $this->subscribePageManager->findPublicPage(id: (int) $request->get('id')); - - if (!$page || $page->isActive() === false) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - return $this->json($normalizer->normalize($page), Response::HTTP_OK); - } - - #[Route('', name: 'create', methods: ['POST'])] + #[Route('/', name: 'create', methods: ['POST'])] #[OA\Post( path: '/api/v2/subscribe-pages', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', @@ -288,6 +185,61 @@ public function createPage(Request $request): JsonResponse return $this->json($this->normalizer->normalize($page), Response::HTTP_CREATED); } + #[Route('/{id}', name: 'get', requirements: ['id' => '\\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/subscribe-pages/{id}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Get subscribe page', + tags: ['subscriptions'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'id', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage'), + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function getPage(Request $request): JsonResponse + { + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { + throw $this->createAccessDeniedException('You are not allowed to view subscribe pages.'); + } + + $page = $this->subscribePageManager->findPage(id: (int) $request->get('id')); + if (!$page) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + return $this->json($this->normalizer->normalize($page), Response::HTTP_OK); + } + #[Route('/{id}', name: 'update', requirements: ['id' => '\\d+'], methods: ['PUT'])] #[OA\Put( path: '/api/v2/subscribe-pages/{id}', @@ -433,102 +385,4 @@ public function deletePage( return $this->json(null, Response::HTTP_NO_CONTENT); } - - #[Route( - '/{id}/lists/{listId}/subscribers', - name: 'subscribe', - requirements: ['listId' => '\d+', 'id' => '\d+'], - methods: ['POST'] - )] - #[OA\Post( - path: '/api/v2/subscribe-pages/{id}/lists/{listId}/subscribers', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.' . - 'Subscribe subscriber to a list from subscribe page.', - summary: 'Create subscription', - requestBody: new OA\RequestBody( - description: '', - required: true, - content: new OA\JsonContent(ref: '#/components/schemas/PublicSubscriptionRequest') - ), - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'listId', - description: 'List ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ), - ], - responses: [ - new OA\Response( - response: 201, - description: 'Success', - content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Subscription') - ) - ), - new OA\Response( - response: 400, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') - ), - new OA\Response( - response: 404, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') - ), - new OA\Response( - response: 422, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') - ), - ] - )] - public function subscribe( - Request $request, - int $id, - #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, - ): JsonResponse { - $page = $this->subscribePageManager->findPublicPage(id: $id); - if (!$list || !$page) { - throw $this->createNotFoundException('Subscriber list or subscribe page not found.'); - } - - /** @var PublicSubscriptionRequest $subscriptionRequest */ - $subscriptionRequest = $this->validator->validate( - request: $request, - dtoClass: PublicSubscriptionRequest::class, - beforeValidation: static function (PublicSubscriptionRequest $dto) use ($page): void { - $dto->setSubscribePage($page); - } - ); - $subscriberEmail = $subscriptionRequest->email; - $subscriptions = $this->subscriptionManager->createSubscriptions( - subscriberList: $list, - emails: [$subscriberEmail], - autoConfirm: false, - ); - $this->entityManager->flush(); - - if ($subscriptionRequest->attributes !== []) { - $this->subscriberAttributeManager->processAttributes( - subscriber: $subscriptions[0]->getSubscriber(), - attributeData: $subscriptionRequest->attributes - ); - } - $this->entityManager->flush(); - - $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); - - return $this->json($normalized, Response::HTTP_CREATED); - } } diff --git a/src/Subscription/Controller/SubscribePagePublicController.php b/src/Subscription/Controller/SubscribePagePublicController.php new file mode 100644 index 00000000..c160c51e --- /dev/null +++ b/src/Subscription/Controller/SubscribePagePublicController.php @@ -0,0 +1,162 @@ + '\\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/public/subscribe-pages/{pageId}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', + summary: 'Get public subscribe page (placeholders replaced with actual values)', + tags: ['public'], + parameters: [ + new OA\Parameter( + name: 'pageId', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/SubscribePagePublic'), + ), + new OA\Response( + response: 404, + description: 'Not Found', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function getPublicPage(Request $request, SubscribePagePublicNormalizer $normalizer): JsonResponse + { + $page = $this->subscribePageManager->findPublicPage(id: (int) $request->get('pageId')); + + if (!$page || $page->isActive() === false) { + throw $this->createNotFoundException('Subscribe page not found'); + } + + return $this->json($normalizer->normalize($page), Response::HTTP_OK); + } + + #[Route('/{pageId}', name: 'subscribe', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/public/subscribe-pages/{pageId}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.' . + 'Subscribe subscriber to a list from subscribe page.', + summary: 'Create subscription', + requestBody: new OA\RequestBody( + description: '', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/PublicSubscriptionRequest') + ), + tags: ['public'], + parameters: [ + new OA\Parameter( + name: 'pageId', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Subscription') + ) + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function subscribe(Request $request, int $pageId): JsonResponse + { + $page = $this->subscribePageManager->findPublicPage(id: $pageId); + if (!$page) { + throw $this->createNotFoundException('Subscriber subscribe page not found.'); + } + + /** @var PublicSubscriptionRequest $subscriptionRequest */ + $subscriptionRequest = $this->validator->validate( + request: $request, + dtoClass: PublicSubscriptionRequest::class, + beforeValidation: static function (PublicSubscriptionRequest $dto) use ($page): void { + $dto->setSubscribePage($page); + } + ); + + $list = $this->entityManager->getRepository(SubscriberList::class)->find($subscriptionRequest->listId); + $subscriptions = $this->subscriptionManager->createSubscriptions( + subscriberList: $list, + emails: [$subscriptionRequest->email], + autoConfirm: false, + ); + $this->entityManager->flush(); + + if ($subscriptionRequest->attributes !== []) { + $this->subscriberAttributeManager->processAttributes( + subscriber: $subscriptions[0]->getSubscriber(), + attributeData: $subscriptionRequest->attributes + ); + } + $this->entityManager->flush(); + + $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); + + return $this->json($normalized, Response::HTTP_CREATED); + } +} diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index e151dd60..44b6e5cf 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -181,70 +181,6 @@ public function getList( return $this->json($this->normalizer->normalize($list), Response::HTTP_OK); } - #[Route('/{listId}/public', name: 'get_one_public', requirements: ['listId' => '\d+'], methods: ['GET'])] - #[OA\Get( - path: '/api/v2/lists/{listId}/public', - description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . - 'Returns a single subscriber list with specified ID.', - summary: 'Gets a subscriber list.', - tags: ['lists'], - parameters: [ - new OA\Parameter( - name: 'listId', - description: 'List ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'string') - ), - ], - responses: [ - new OA\Response( - response: 200, - description: 'Success', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'id', type: 'integer', example: 1), - new OA\Property(property: 'name', type: 'string', example: 'Newsletter subscribers'), - new OA\Property(property: 'list_position', type: 'integer', example: 1), - new OA\Property( - property: 'description', - type: 'string', - example: 'Main public list', - nullable: true - ) - ] - ) - ), - new OA\Response( - response: 404, - description: 'Failure', - content: new OA\JsonContent( - properties: [ - new OA\Property( - property: 'message', - type: 'string', - example: 'There is no list with that ID.' - ) - ], - type: 'object' - ) - ), - ] - )] - public function getPublicList(#[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null): JsonResponse - { - if (!$list) { - throw $this->createNotFoundException('Subscriber list not found.'); - } - - return $this->json([ - 'id' => $list->getId(), - 'name' => $list->getName(), - 'description' => $list->getDescription(), - 'list_position' => $list->getListPosition(), - ], Response::HTTP_OK); - } - #[Route('/{listId}', name: 'delete', requirements: ['listId' => '\d+'], methods: ['DELETE'])] #[OA\Delete( path: '/api/v2/lists/{listId}', diff --git a/src/Subscription/Request/PublicSubscriptionRequest.php b/src/Subscription/Request/PublicSubscriptionRequest.php index 4bc62fb2..e91181b8 100644 --- a/src/Subscription/Request/PublicSubscriptionRequest.php +++ b/src/Subscription/Request/PublicSubscriptionRequest.php @@ -7,6 +7,7 @@ use OpenApi\Attributes as OA; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\RestBundle\Common\Request\RequestInterface; +use PhpList\RestBundle\Subscription\Validator\Constraint\ListExistsPublic; use PhpList\RestBundle\Subscription\Validator\Constraint\ValidPublicSubscription; use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Validator\Constraints as Assert; @@ -21,11 +22,16 @@ example: 'lia@example.com' ), new OA\Property( - property: 'confirmEmail', + property: 'confirm_email', type: 'string', format: 'email', example: 'lia@example.com' ), + new OA\Property( + property: 'list_id', + type: 'integer', + example: 1 + ), new OA\Property( property: 'attributes', type: 'object', @@ -45,6 +51,10 @@ class PublicSubscriptionRequest implements RequestInterface #[Assert\Email] public ?string $email = null; + #[ListExistsPublic] + #[Assert\Type(type: 'integer')] + public ?int $listId = null; + #[Assert\NotBlank] #[Assert\Email] #[Assert\EqualTo( diff --git a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php index dc95cda9..befdb84f 100644 --- a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php +++ b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\Core\Domain\Subscription\Model\SubscribePageData; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; #[OA\Schema( @@ -31,6 +32,7 @@ class SubscribePagePublicNormalizer implements NormalizerInterface { public function __construct( private readonly SubscriberAttributeDefinitionRepository $attributeDefinitionRepository, + private readonly SubscriberListRepository $subscriberListRepository, ) { } @@ -51,9 +53,13 @@ public function normalize($object, string $format = null, array $context = []): function (array $carry, SubscribePageData $data) { $value = $data->getData(); if ($data->getName() === 'attributes') { - $ids = array_filter(explode('+', $data->getData())); + $ids = array_filter(explode('+', $data->getData())); $value = $this->getAttributeDefinitions($ids); } + if ($data->getName() === 'lists') { + $ids = array_filter(explode(',', $data->getData())); + $value = $this->getLists($ids); + } $carry[$data->getName()] = $value; return $carry; @@ -82,6 +88,22 @@ private function getAttributeDefinitions(array $ids): array return $result; } + private function getLists(array $ids): array + { + $lists = $this->subscriberListRepository->getPublicByIds($ids); + $result = []; + foreach ($lists as $list) { + $result[] = [ + 'id' => $list->getId(), + 'name' => $list->getName(), + 'description' => $list->getDescription(), + 'list_position' => $list->getListPosition(), + ]; + } + + return $result; + } + /** * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ diff --git a/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php b/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php index 150b06d2..ff39195c 100644 --- a/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php +++ b/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php @@ -6,14 +6,15 @@ use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; use PhpList\Core\Domain\Subscription\Model\SubscribePage; -use PhpList\Core\Domain\Subscription\Model\SubscribePageData; -use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; +use Symfony\Component\String\UnicodeString; class PublicSubscriptionAttributeRuleProvider { public function __construct( private readonly SubscriberAttributeDefinitionRepository $attributeDefinitionRepository, + private readonly SubscribePageManager $subscribePageManager, ) { } @@ -24,7 +25,6 @@ public function __construct( * type:AttributeTypeEnum|null, * required:bool, * allowed:array, - * max_length:int|null * }> */ public function getRules(SubscribePage $page): array @@ -37,26 +37,16 @@ public function getRules(SubscribePage $page): array ? $this->attributeDefinitionRepository->getByIds($selectedIds) : ($hasPageData ? [] : $this->attributeDefinitionRepository->findBy([])); - $legacyOverrides = $this->extractLegacyOverrides($pageData); - $modernOverrides = $this->extractModernOverrides($pageData); + $legacyOverrides = $this->subscribePageManager->extractLegacyOverrides($pageData); $rules = []; foreach ($definitions as $definition) { $id = $definition->getId(); - if ($id === null) { - continue; - } - - $override = $modernOverrides[$id] ?? $legacyOverrides[$id] ?? []; - $shouldUse = !array_key_exists('use', $override) || (bool)$override['use']; - if (!$shouldUse) { - continue; - } - - $key = mb_strtolower(trim($definition->getName())); - if ($key === '') { - continue; - } + $override = $legacyOverrides[$id] ?? []; + $key = (new UnicodeString($definition->getName())) + ->snake() + ->lower() + ->toString(); $rules[$key] = [ 'id' => $id, @@ -65,8 +55,7 @@ public function getRules(SubscribePage $page): array 'required' => array_key_exists('required', $override) ? (bool) $override['required'] : (bool) $definition->isRequired(), - 'allowed' => $this->allowedOptions($definition), - 'max_length' => $this->resolveMaxLength($override), + 'allowed' => array_fill_keys(array_column($definition->getOptions(), 'id'), true), ]; } @@ -80,9 +69,6 @@ private function toMap(SubscribePage $page): array { $map = []; foreach ($page->getData() as $item) { - if (!$item instanceof SubscribePageData) { - continue; - } $map[$item->getName()] = $item->getData(); } @@ -101,104 +87,4 @@ private function parseSelectedAttributeIds(?string $raw): array $ids = array_filter(array_map('trim', explode('+', $raw)), static fn (string $id): bool => $id !== ''); return array_values(array_unique(array_map('intval', $ids))); } - - /** - * @param array $pageData - * @return array - */ - private function extractLegacyOverrides(array $pageData): array - { - $result = []; - foreach ($pageData as $key => $value) { - if (!preg_match('/^attribute(\d{1,})$/', $key, $matches)) { - continue; - } - - $id = (int) $matches[1]; - $parts = explode('###', (string) $value); - // phpList 3 structure: order###default###use###required - if (isset($parts[2])) { - $result[$id]['use'] = $this->isTruthy($parts[2]); - } - if (isset($parts[3])) { - $result[$id]['required'] = $this->isTruthy($parts[3]); - } - if (isset($parts[4]) && is_numeric($parts[4])) { - $result[$id]['max_length'] = (int) $parts[4]; - } - } - - return $result; - } - - /** - * @param array $pageData - * @return array - */ - private function extractModernOverrides(array $pageData): array - { - $result = []; - - foreach ($pageData as $key => $value) { - if (!preg_match('/^attribute_(\d+)_(use|required|maxlength)$/', $key, $matches)) { - continue; - } - - $id = (int) $matches[1]; - $suffix = $matches[2]; - - if ($suffix === 'maxlength') { - if (is_numeric($value)) { - $result[$id]['max_length'] = (int) $value; - } - continue; - } - - $result[$id][$suffix] = $this->isTruthy($value); - } - - return $result; - } - - private function isTruthy(mixed $value): bool - { - if (is_bool($value)) { - return $value; - } - - if (is_numeric($value)) { - return (int) $value === 1; - } - - return in_array(mb_strtolower(trim((string) $value)), ['true', 'yes', 'on'], true); - } - - /** - * @return array - */ - private function allowedOptions(SubscriberAttributeDefinition $definition): array - { - $allowed = []; - foreach ($definition->getOptions() as $option) { - if ($option->id !== null) { - $allowed[(string) $option->id] = true; - } - } - - return $allowed; - } - - /** - * @param array{use?:bool,required?:bool,max_length?:int} $override - */ - private function resolveMaxLength(array $override): ?int - { - if (!array_key_exists('max_length', $override)) { - return null; - } - - $max = (int) $override['max_length']; - return $max > 0 ? $max : null; - } } - diff --git a/src/Subscription/Validator/Constraint/ListExistsPublic.php b/src/Subscription/Validator/Constraint/ListExistsPublic.php new file mode 100644 index 00000000..9b8a00c2 --- /dev/null +++ b/src/Subscription/Validator/Constraint/ListExistsPublic.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Subscription/Validator/Constraint/ListExistsPublicValidator.php b/src/Subscription/Validator/Constraint/ListExistsPublicValidator.php new file mode 100644 index 00000000..bfa4f953 --- /dev/null +++ b/src/Subscription/Validator/Constraint/ListExistsPublicValidator.php @@ -0,0 +1,35 @@ +subscriberListRepository->findBy(['id' => (int)$value, 'public' => true]); + + if (!$existingList) { + throw new NotFoundHttpException('Subscriber list does not exists.'); + } + } +} diff --git a/src/Subscription/Validator/Constraint/ListExistsValidator.php b/src/Subscription/Validator/Constraint/ListExistsValidator.php index 8cbcd216..4eff2f4a 100644 --- a/src/Subscription/Validator/Constraint/ListExistsValidator.php +++ b/src/Subscription/Validator/Constraint/ListExistsValidator.php @@ -9,7 +9,6 @@ use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; -use Symfony\Component\Validator\Exception\UnexpectedValueException; class ListExistsValidator extends ConstraintValidator { diff --git a/src/Subscription/Validator/Constraint/ValidPublicSubscription.php b/src/Subscription/Validator/Constraint/ValidPublicSubscription.php index 7deea36a..18fec6b2 100644 --- a/src/Subscription/Validator/Constraint/ValidPublicSubscription.php +++ b/src/Subscription/Validator/Constraint/ValidPublicSubscription.php @@ -25,4 +25,3 @@ public function validatedBy(): string return ValidPublicSubscriptionValidator::class; } } - diff --git a/src/Subscription/Validator/Constraint/ValidPublicSubscriptionValidator.php b/src/Subscription/Validator/Constraint/ValidPublicSubscriptionValidator.php index b9651cd0..6a8e1f19 100644 --- a/src/Subscription/Validator/Constraint/ValidPublicSubscriptionValidator.php +++ b/src/Subscription/Validator/Constraint/ValidPublicSubscriptionValidator.php @@ -4,6 +4,7 @@ namespace PhpList\RestBundle\Subscription\Validator\Constraint; +use DateTimeImmutable; use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; use PhpList\RestBundle\Subscription\Request\PublicSubscriptionRequest; use PhpList\RestBundle\Subscription\Service\PublicSubscriptionAttributeRuleProvider; @@ -13,6 +14,8 @@ class ValidPublicSubscriptionValidator extends ConstraintValidator { + private const VALID_CHECKBOX_VALUES = ['on', 'off', 'true', 'false', 'yes', 'no']; + public function __construct( private readonly PublicSubscriptionAttributeRuleProvider $ruleProvider, ) { @@ -24,35 +27,13 @@ public function validate($value, Constraint $constraint): void throw new UnexpectedTypeException($constraint, ValidPublicSubscription::class); } - if (!$value instanceof PublicSubscriptionRequest) { - return; - } - - $page = $value->getSubscribePage(); - if ($page === null) { + if ($this->doesNotSupportValidation($value)) { return; } - $rules = $this->ruleProvider->getRules($page); - $submitted = is_array($value->attributes) ? $value->attributes : []; - $submittedByKey = []; - foreach ($submitted as $rawKey => $rawValue) { - $key = mb_strtolower(trim((string) $rawKey)); - if ($key === '') { - continue; - } - $submittedByKey[$key] = ['path' => (string) $rawKey, 'value' => $rawValue]; - } - - if ($constraint->rejectUnknownAttributes) { - foreach ($submittedByKey as $key => $entry) { - if (!isset($rules[$key])) { - $this->context->buildViolation($constraint->unknownAttributeMessage) - ->atPath('attributes.' . $entry['path']) - ->addViolation(); - } - } - } + $rules = $this->ruleProvider->getRules($value->getSubscribePage()); + $submittedByKey = $this->mapSubmittedByKey($value); + $this->rejectUnknownAttributes($submittedByKey, $rules, $constraint); foreach ($rules as $key => $rule) { $submittedEntry = $submittedByKey[$key] ?? null; @@ -74,16 +55,6 @@ public function validate($value, Constraint $constraint): void $this->context->buildViolation($constraint->invalidValueMessage) ->atPath('attributes.' . $pathKey) ->addViolation(); - continue; - } - - if ($rule['max_length'] !== null) { - $asString = trim((string) $submittedValue); - if (mb_strlen($asString) > $rule['max_length']) { - $this->context->buildViolation($constraint->invalidValueMessage) - ->atPath('attributes.' . $pathKey) - ->addViolation(); - } } } } @@ -113,43 +84,43 @@ private function isEmptyValue(mixed $value, ?AttributeTypeEnum $type): bool */ private function isValidTypeValue(mixed $value, array $rule): bool { - $type = $rule['type']; - $allowed = $rule['allowed']; - - if ($type === AttributeTypeEnum::Checkbox) { - return is_bool($value) - || is_numeric($value) - || in_array(mb_strtolower(trim((string) $value)), ['on', 'off', 'true', 'false', 'yes', 'no'], true); - } - - if ($type === AttributeTypeEnum::CheckboxGroup) { - if (!is_array($value)) { - return false; - } - - foreach ($value as $item) { - if (!isset($allowed[(string) $item])) { - return false; - } - } + return match ($rule['type']) { + AttributeTypeEnum::Checkbox => $this->isValidCheckboxValue($value), + AttributeTypeEnum::CheckboxGroup => $this->isValidCheckboxGroupValue($value, $rule['allowed']), + AttributeTypeEnum::Select, + AttributeTypeEnum::Radio => isset($rule['allowed'][(string) $value]), + AttributeTypeEnum::Date => $this->isValidDateValue($value), + AttributeTypeEnum::Number => is_numeric($value), + default => $this->isValidScalarValue($value), + }; + } - return true; + private function isValidScalarValue(mixed $value): bool + { + if (is_array($value) || is_object($value)) { + return false; } - if ($type === AttributeTypeEnum::Select || $type === AttributeTypeEnum::Radio) { - return isset($allowed[(string) $value]); - } + return true; + } - if ($type === AttributeTypeEnum::Date) { - return $this->isValidDateValue($value); - } + private function isValidCheckboxValue(mixed $value): bool + { + return is_bool($value) + || is_numeric($value) + || in_array(mb_strtolower(trim((string) $value)), self::VALID_CHECKBOX_VALUES, true); + } - if ($type === AttributeTypeEnum::Number) { - return is_numeric($value); + private function isValidCheckboxGroupValue(mixed $value, mixed $allowed): bool + { + if (!is_array($value)) { + return false; } - if (is_array($value) || is_object($value)) { - return false; + foreach ($value as $item) { + if (!isset($allowed[(string) $item])) { + return false; + } } return true; @@ -158,15 +129,7 @@ private function isValidTypeValue(mixed $value, array $rule): bool private function isValidDateValue(mixed $value): bool { if (is_array($value)) { - $year = $value['year'] ?? $value['yyyy'] ?? null; - $month = $value['month'] ?? $value['mm'] ?? null; - $day = $value['day'] ?? $value['dd'] ?? null; - - if (!is_numeric($year) || !is_numeric($month) || !is_numeric($day)) { - return false; - } - - return checkdate((int) $month, (int) $day, (int) $year); + return $this->isValidDateArray($value); } $stringValue = trim((string) $value); @@ -174,7 +137,7 @@ private function isValidDateValue(mixed $value): bool return false; } - $date = \DateTimeImmutable::createFromFormat('Y-m-d', $stringValue); + $date = DateTimeImmutable::createFromFormat('Y-m-d', $stringValue); if ($date !== false && $date->format('Y-m-d') === $stringValue) { return true; } @@ -182,6 +145,19 @@ private function isValidDateValue(mixed $value): bool return strtotime($stringValue) !== false; } + private function isValidDateArray(array $value): bool + { + $year = $value['year'] ?? $value['yyyy'] ?? null; + $month = $value['month'] ?? $value['mm'] ?? null; + $day = $value['day'] ?? $value['dd'] ?? null; + + if (!is_numeric($year) || !is_numeric($month) || !is_numeric($day)) { + return false; + } + + return checkdate((int) $month, (int) $day, (int) $year); + } + private function toBool(mixed $value): bool { if (is_bool($value)) { @@ -194,4 +170,48 @@ private function toBool(mixed $value): bool return in_array(mb_strtolower(trim((string) $value)), ['1', 'on', 'true', 'yes'], true); } + + private function doesNotSupportValidation($value): bool + { + if (!$value instanceof PublicSubscriptionRequest) { + return true; + } + + $page = $value->getSubscribePage(); + if ($page === null) { + return true; + } + + return false; + } + + private function rejectUnknownAttributes( + array $submittedByKey, + array $rules, + ValidPublicSubscription $constraint + ): void { + if ($constraint->rejectUnknownAttributes) { + foreach ($submittedByKey as $key => $entry) { + if (!isset($rules[$key])) { + $this->context->buildViolation($constraint->unknownAttributeMessage) + ->atPath('attributes.' . $entry['path']) + ->addViolation(); + } + } + } + } + + private function mapSubmittedByKey(mixed $value): array + { + $submittedByKey = []; + foreach ($value->attributes as $rawKey => $rawValue) { + $key = mb_strtolower(trim((string) $rawKey)); + if ($key === '') { + continue; + } + $submittedByKey[$key] = ['path' => (string) $rawKey, 'value' => $rawValue]; + } + + return $submittedByKey; + } } diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index b6879e26..c21b9ac1 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -245,16 +245,19 @@ public function testPublicSubscribeCreatesSubscriptionAndAttributes(): void $response = $this->getDecodedJsonResponseContent(); self::assertSame('public@example.com', $response[0]['subscriber']['email'] ?? null); - $subscriber = $this->entityManager?->getRepository(Subscriber::class)->findOneBy(['email' => 'public@example.com']); + $subscriber = $this->entityManager?->getRepository(Subscriber::class) + ->findOneBy(['email' => 'public@example.com']); self::assertInstanceOf(Subscriber::class, $subscriber); - $definition = $this->entityManager?->getRepository(SubscriberAttributeDefinition::class)->findOneBy(['name' => 'Country']); + $definition = $this->entityManager?->getRepository(SubscriberAttributeDefinition::class) + ->findOneBy(['name' => 'Country']); self::assertInstanceOf(SubscriberAttributeDefinition::class, $definition); - $value = $this->entityManager?->getRepository(SubscriberAttributeValue::class)->findOneBy([ - 'subscriber' => $subscriber, - 'attributeDefinition' => $definition, - ]); + $value = $this->entityManager?->getRepository(SubscriberAttributeValue::class) + ->findOneBy([ + 'subscriber' => $subscriber, + 'attributeDefinition' => $definition, + ]); self::assertInstanceOf(SubscriberAttributeValue::class, $value); self::assertSame('Armenia', $value->getValue()); } diff --git a/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php index d8647779..5c91d541 100644 --- a/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php +++ b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php @@ -7,22 +7,31 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; use PHPUnit\Framework\TestCase; use stdClass; class SubscribePagePublicNormalizerTest extends TestCase { - public function testSupportsNormalization(): void + private SubscribePagePublicNormalizer $normalizer; + + protected function setUp(): void { - $normalizer = new SubscribePagePublicNormalizer( - $this->createMock(SubscriberAttributeDefinitionRepository::class) + parent::setUp(); + + $this->normalizer = new SubscribePagePublicNormalizer( + $this->createMock(SubscriberAttributeDefinitionRepository::class), + $this->createMock(SubscriberListRepository::class) ); + } + public function testSupportsNormalization(): void + { $page = $this->createMock(SubscribePage::class); - $this->assertTrue($normalizer->supportsNormalization($page)); - $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + $this->assertTrue($this->normalizer->supportsNormalization($page)); + $this->assertFalse($this->normalizer->supportsNormalization(new stdClass())); } public function testNormalizeReturnsExpectedArray(): void @@ -35,24 +44,17 @@ public function testNormalizeReturnsExpectedArray(): void $page->method('isActive')->willReturn(true); $page->method('getOwner')->willReturn($owner); - $normalizer = new SubscribePagePublicNormalizer( - $this->createMock(SubscriberAttributeDefinitionRepository::class) - ); - $expected = [ 'id' => 42, 'title' => 'welcome@example.org', 'data' => [], ]; - $this->assertSame($expected, $normalizer->normalize($page)); + $this->assertSame($expected, $this->normalizer->normalize($page)); } public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void { - $normalizer = new SubscribePagePublicNormalizer( - $this->createMock(SubscriberAttributeDefinitionRepository::class) - ); - $this->assertSame([], $normalizer->normalize(new stdClass())); + $this->assertSame([], $this->normalizer->normalize(new stdClass())); } } diff --git a/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php b/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php index a0ea7060..04aa38fc 100644 --- a/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php +++ b/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php @@ -5,11 +5,11 @@ namespace PhpList\RestBundle\Tests\Unit\Subscription\Service; use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; -use PhpList\Core\Domain\Subscription\Model\Dto\DynamicListAttrDto; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\Core\Domain\Subscription\Model\SubscribePageData; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; use PhpList\RestBundle\Subscription\Service\PublicSubscriptionAttributeRuleProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -18,40 +18,16 @@ class PublicSubscriptionAttributeRuleProviderTest extends TestCase { private SubscriberAttributeDefinitionRepository&MockObject $repository; private PublicSubscriptionAttributeRuleProvider $provider; + private SubscribePageManager&MockObject $subscribePageManager; protected function setUp(): void { $this->repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $this->provider = new PublicSubscriptionAttributeRuleProvider($this->repository); - } - - public function testBuildsRulesWithModernOverridesAndAllowedOptions(): void - { - $page = (new SubscribePage())->setData([ - (new SubscribePageData())->setId(1)->setName('attributes')->setData('1'), - (new SubscribePageData())->setId(1)->setName('attribute_1_required')->setData('1'), - (new SubscribePageData())->setId(1)->setName('attribute_1_maxlength')->setData('5'), - ]); - - $definition = $this->createMock(SubscriberAttributeDefinition::class); - $definition->method('getId')->willReturn(1); - $definition->method('getName')->willReturn('Country'); - $definition->method('getType')->willReturn(AttributeTypeEnum::Select); - $definition->method('isRequired')->willReturn(false); - $definition->method('getOptions')->willReturn([ - new DynamicListAttrDto(10, 'Armenia', 1), - new DynamicListAttrDto(11, 'France', 2), - ]); - - $this->repository->expects($this->once())->method('getByIds')->with([1])->willReturn([$definition]); - - $rules = $this->provider->getRules($page); - - $this->assertArrayHasKey('country', $rules); - $this->assertTrue($rules['country']['required']); - $this->assertSame(5, $rules['country']['max_length']); - $this->assertArrayHasKey('10', $rules['country']['allowed']); - $this->assertArrayHasKey('11', $rules['country']['allowed']); + $this->subscribePageManager = $this->createMock(SubscribePageManager::class); + $this->provider = new PublicSubscriptionAttributeRuleProvider( + attributeDefinitionRepository: $this->repository, + subscribePageManager: $this->subscribePageManager + ); } public function testExcludesAttributesDisabledInLegacyOverride(): void @@ -68,11 +44,21 @@ public function testExcludesAttributesDisabledInLegacyOverride(): void $definition->method('isRequired')->willReturn(true); $definition->method('getOptions')->willReturn([]); - $this->repository->expects($this->once())->method('getByIds')->with([2])->willReturn([$definition]); + $this->repository->expects($this->once()) + ->method('getByIds') + ->with([2]) + ->willReturn([$definition]); $rules = $this->provider->getRules($page); - $this->assertSame([], $rules); + $this->assertSame([ + 'state' => [ + 'id' => 2, + 'key' => 'state', + 'type' => AttributeTypeEnum::TextLine, + 'required' => true, + 'allowed' => [], + ] + ], $rules); } } - diff --git a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php index 8c980ad3..57a9b619 100644 --- a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php +++ b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php @@ -114,8 +114,12 @@ public function testRejectsInvalidCheckboxGroupOption(): void ]); $builder = $this->createMock(ConstraintViolationBuilderInterface::class); - $builder->expects($this->once())->method('atPath')->with('attributes.country')->willReturnSelf(); - $builder->expects($this->once())->method('addViolation'); + $builder->expects($this->once()) + ->method('atPath') + ->with('attributes.country') + ->willReturnSelf(); + $builder->expects($this->once()) + ->method('addViolation'); $this->context->expects($this->once()) ->method('buildViolation') @@ -125,4 +129,3 @@ public function testRejectsInvalidCheckboxGroupOption(): void $this->validator->validate($request, new ValidPublicSubscription()); } } - From e7404f41e6693f794b9d4874c0d445da4c3dd9b9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Jun 2026 11:59:07 +0400 Subject: [PATCH 14/18] Add tests --- .../Controller/SubscribePageController.php | 18 +-- .../SubscribePagePublicController.php | 13 +- .../Request/PublicSubscriptionRequest.php | 1 + .../SubscribePagePublicNormalizer.php | 67 +++++++++-- .../SubscribePageControllerTest.php | 51 +------- .../SubscribePagePublicControllerTest.php | 113 ++++++++++++++++++ .../SubscriberAttributeDefinitionFixture.php | 1 - .../SubscriberAttributeValueFixture.php | 1 - .../Request/PublicSubscriptionRequestTest.php | 45 +++++++ .../Constraint/ListExistsPublicTest.php | 27 +++++ .../ListExistsPublicValidatorTest.php | 79 ++++++++++++ .../ValidPublicSubscriptionTest.php | 38 ++++++ 12 files changed, 382 insertions(+), 72 deletions(-) create mode 100644 tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php create mode 100644 tests/Unit/Subscription/Request/PublicSubscriptionRequestTest.php create mode 100644 tests/Unit/Subscription/Validator/Constraint/ListExistsPublicTest.php create mode 100644 tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php create mode 100644 tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionTest.php diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 59fa1aa9..422768d3 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -41,7 +41,7 @@ public function __construct( path: '/api/v2/subscribe-pages', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', summary: 'Get subscribe pages list', - tags: ['subscriptions'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -137,7 +137,7 @@ className: SubscribePage::class, ] ) ), - tags: ['subscriptions'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -175,10 +175,14 @@ public function createPage(Request $request): JsonResponse /** @var SubscribePageRequest $createRequest */ $createRequest = $this->validator->validate($request, SubscribePageRequest::class); - $page = $this->subscribePageManager->createPage($createRequest->title, $createRequest->active, $admin); + $page = $this->subscribePageManager->createPage( + title: $createRequest->title, + active: $createRequest->active, + owner: $admin + ); if ($createRequest->hasData()) { $this->entityManager->flush(); - $this->subscribePageManager->syncPageData($createRequest->getDataMap(), $page); + $this->subscribePageManager->syncPageData(data: $createRequest->getDataMap(), page: $page); } $this->entityManager->flush(); @@ -190,7 +194,7 @@ public function createPage(Request $request): JsonResponse path: '/api/v2/subscribe-pages/{id}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', summary: 'Get subscribe page', - tags: ['subscriptions'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -266,7 +270,7 @@ public function getPage(Request $request): JsonResponse ] ) ), - tags: ['subscriptions'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -336,7 +340,7 @@ public function updatePage( path: '/api/v2/subscribe-pages/{id}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', summary: 'Delete subscribe page', - tags: ['subscriptions'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', diff --git a/src/Subscription/Controller/SubscribePagePublicController.php b/src/Subscription/Controller/SubscribePagePublicController.php index c160c51e..556116c6 100644 --- a/src/Subscription/Controller/SubscribePagePublicController.php +++ b/src/Subscription/Controller/SubscribePagePublicController.php @@ -6,7 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; @@ -16,7 +16,6 @@ use PhpList\RestBundle\Subscription\Request\PublicSubscriptionRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; use PhpList\RestBundle\Subscription\Serializer\SubscriptionNormalizer; -use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -33,6 +32,7 @@ public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly SubscriptionNormalizer $subscriptionNormalizer, private readonly SubscriberAttributeManager $subscriberAttributeManager, + private readonly SubscriberListRepository $subscriberListRepository, ) { parent::__construct($authentication, $validator); } @@ -42,7 +42,7 @@ public function __construct( path: '/api/v2/public/subscribe-pages/{pageId}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', summary: 'Get public subscribe page (placeholders replaced with actual values)', - tags: ['public'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'pageId', @@ -87,7 +87,7 @@ public function getPublicPage(Request $request, SubscribePagePublicNormalizer $n required: true, content: new OA\JsonContent(ref: '#/components/schemas/PublicSubscriptionRequest') ), - tags: ['public'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'pageId', @@ -139,7 +139,10 @@ public function subscribe(Request $request, int $pageId): JsonResponse } ); - $list = $this->entityManager->getRepository(SubscriberList::class)->find($subscriptionRequest->listId); + $list = $this->subscriberListRepository->find($subscriptionRequest->listId); + if ($list === null) { + throw $this->createNotFoundException('Subscriber list does not exists.'); + } $subscriptions = $this->subscriptionManager->createSubscriptions( subscriberList: $list, emails: [$subscriptionRequest->email], diff --git a/src/Subscription/Request/PublicSubscriptionRequest.php b/src/Subscription/Request/PublicSubscriptionRequest.php index e91181b8..3c9e3fb1 100644 --- a/src/Subscription/Request/PublicSubscriptionRequest.php +++ b/src/Subscription/Request/PublicSubscriptionRequest.php @@ -52,6 +52,7 @@ class PublicSubscriptionRequest implements RequestInterface public ?string $email = null; #[ListExistsPublic] + #[Assert\NotNull] #[Assert\Type(type: 'integer')] public ?int $listId = null; diff --git a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php index befdb84f..25ab7c24 100644 --- a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php +++ b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php @@ -14,19 +14,65 @@ #[OA\Schema( schema: 'SubscribePagePublic', properties: [ - new OA\Property(property: 'id', type: 'integer', example: 1), - new OA\Property(property: 'title', type: 'string', example: 'Subscribe to our newsletter'), + new OA\Property( + property: 'id', + type: 'integer', + example: 1 + ), + new OA\Property( + property: 'title', + type: 'string', + example: 'Subscribe to our newsletter' + ), new OA\Property( property: 'data', - type: 'array', - items: new OA\Items( - type: 'object', - additionalProperties: new OA\AdditionalProperties( - type: 'string' - ) + properties: [ + new OA\Property( + property: 'attributes', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'type', type: 'string'), + new OA\Property(property: 'required', type: 'boolean'), + new OA\Property(property: 'default_value', type: 'string', nullable: true), + new OA\Property(property: 'list_order', type: 'integer'), + new OA\Property( + property: 'options', + type: 'array', + items: new OA\Items(type: 'object') + ), + ], + type: 'object' + ) + ), + new OA\Property( + property: 'lists', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'description', type: 'string', nullable: true), + new OA\Property(property: 'list_position', type: 'integer'), + ], + type: 'object' + ) + ), + ], + type: 'object', + additionalProperties: new OA\AdditionalProperties( + oneOf: [ + new OA\Schema(type: 'string'), + new OA\Schema(type: 'integer'), + new OA\Schema(type: 'boolean'), + new OA\Schema(type: 'array', items: new OA\Items(type: 'object')), + new OA\Schema(type: 'object'), + ] ) ), - ], + ] )] class SubscribePagePublicNormalizer implements NormalizerInterface { @@ -53,7 +99,8 @@ public function normalize($object, string $format = null, array $context = []): function (array $carry, SubscribePageData $data) { $value = $data->getData(); if ($data->getName() === 'attributes') { - $ids = array_filter(explode('+', $data->getData())); + $attributeIds = str_replace('+', ',', $data->getData()); + $ids = array_filter(explode(',', $attributeIds)); $value = $this->getAttributeDefinitions($ids); } if ($data->getName() === 'lists') { diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index c21b9ac1..6635bdd4 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -4,16 +4,11 @@ namespace PhpList\RestBundle\Tests\Integration\Subscription\Controller; -use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; -use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; use PhpList\RestBundle\Subscription\Controller\SubscribePageController; use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController; use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture; use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorTokenFixture; use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscribePageFixture; -use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberAttributeDefinitionFixture; -use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberListFixture; class SubscribePageControllerTest extends AbstractTestController { @@ -80,7 +75,7 @@ public function testCreateSubscribePageWithoutSessionReturnsForbidden(): void ], ], JSON_THROW_ON_ERROR); - $this->jsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); + $this->jsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload); $this->assertHttpForbidden(); } @@ -96,7 +91,7 @@ public function testCreateSubscribePageWithSessionCreatesPage(): void ], ], JSON_THROW_ON_ERROR); - $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); + $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload); $this->assertHttpCreated(); $data = $this->getDecodedJsonResponseContent(); @@ -143,7 +138,7 @@ public function testCreateSubscribePageWithDataMissingValueReturnsUnprocessableE ], ], JSON_THROW_ON_ERROR); - $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages', content: $payload); + $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload); $this->assertHttpUnprocessableEntity(); } @@ -221,44 +216,4 @@ public function testDeleteSubscribePageWithSessionNotFound(): void $this->authenticatedJsonRequest('DELETE', '/api/v2/subscribe-pages/9999'); $this->assertHttpNotFound(); } - - public function testPublicSubscribeCreatesSubscriptionAndAttributes(): void - { - $this->loadFixtures([ - AdministratorFixture::class, - SubscribePageFixture::class, - SubscriberListFixture::class, - SubscriberAttributeDefinitionFixture::class, - ]); - - $payload = json_encode([ - 'email' => 'public@example.com', - 'confirmEmail' => 'public@example.com', - 'attributes' => [ - 'Country' => 'Armenia', - ], - ]); - - $this->jsonRequest('POST', '/api/v2/subscribe-pages/1/lists/1/subscribers', [], [], [], $payload); - $this->assertHttpCreated(); - - $response = $this->getDecodedJsonResponseContent(); - self::assertSame('public@example.com', $response[0]['subscriber']['email'] ?? null); - - $subscriber = $this->entityManager?->getRepository(Subscriber::class) - ->findOneBy(['email' => 'public@example.com']); - self::assertInstanceOf(Subscriber::class, $subscriber); - - $definition = $this->entityManager?->getRepository(SubscriberAttributeDefinition::class) - ->findOneBy(['name' => 'Country']); - self::assertInstanceOf(SubscriberAttributeDefinition::class, $definition); - - $value = $this->entityManager?->getRepository(SubscriberAttributeValue::class) - ->findOneBy([ - 'subscriber' => $subscriber, - 'attributeDefinition' => $definition, - ]); - self::assertInstanceOf(SubscriberAttributeValue::class, $value); - self::assertSame('Armenia', $value->getValue()); - } } diff --git a/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php new file mode 100644 index 00000000..797d2f84 --- /dev/null +++ b/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php @@ -0,0 +1,113 @@ +get(SubscribePagePublicController::class) + ); + } + + public function testGetPublicPageReturnsActivePage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + + $this->jsonRequest('GET', '/api/v2/public/subscribe-pages/1'); + + $this->assertHttpOkay(); + $data = $this->getDecodedJsonResponseContent(); + + self::assertSame(1, $data['id']); + self::assertSame('Welcome Page', $data['title']); + self::assertSame([], $data['data']); + } + + public function testGetPublicPageReturnsNotFoundForInactivePage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + + $this->jsonRequest('GET', '/api/v2/public/subscribe-pages/2'); + + $this->assertHttpNotFound(); + } + + public function testGetPublicPageReturnsNotFoundForUnknownPage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + + $this->jsonRequest('GET', '/api/v2/public/subscribe-pages/9999'); + + $this->assertHttpNotFound(); + } + + public function testSubscribeReturnsNotFoundForUnknownPage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscriberListFixture::class]); + $payload = json_encode([ + 'email' => 'public@example.com', + 'confirm_email' => 'public@example.com', + 'list_id' => 1, + ], JSON_THROW_ON_ERROR); + + $this->jsonRequest('POST', '/api/v2/public/subscribe-pages/9999', [], [], [], $payload); + + $this->assertHttpNotFound(); + } + + public function testSubscribeCreatesSubscriptionAndAttributes(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + SubscribePageFixture::class, + SubscriberListFixture::class, + SubscriberAttributeDefinitionFixture::class, + ]); + + $payload = json_encode([ + 'email' => 'public@example.com', + 'confirm_email' => 'public@example.com', + 'list_id' => 1, + 'attributes' => [ + 'Country' => 'on', + ], + ], JSON_THROW_ON_ERROR); + + $this->jsonRequest('POST', '/api/v2/public/subscribe-pages/1', [], [], [], $payload); + $this->assertHttpCreated(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('public@example.com', $response[0]['subscriber']['email'] ?? null); + + $subscriber = $this->entityManager?->getRepository(Subscriber::class) + ->findOneBy(['email' => 'public@example.com']); + self::assertInstanceOf(Subscriber::class, $subscriber); + + $definition = $this->entityManager?->getRepository(SubscriberAttributeDefinition::class) + ->findOneBy(['name' => 'Country']); + self::assertInstanceOf(SubscriberAttributeDefinition::class, $definition); + + $value = $this->entityManager?->getRepository(SubscriberAttributeValue::class) + ->findOneBy([ + 'subscriber' => $subscriber, + 'attributeDefinition' => $definition, + ]); + self::assertInstanceOf(SubscriberAttributeValue::class, $value); + self::assertSame('on', $value->getValue()); + } +} diff --git a/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php index 3fbb79a2..c85ee74b 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php @@ -20,7 +20,6 @@ public function load(ObjectManager $manager): void $definition->setListOrder(1); $definition->setDefaultValue('US'); $definition->setRequired(true); - $definition->setTableName('list_attributes'); $manager->persist($definition); $manager->flush(); diff --git a/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php index 2219604a..0e7ef3be 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php @@ -22,7 +22,6 @@ public function load(ObjectManager $manager): void $definition->setListOrder(1); $definition->setDefaultValue('US'); $definition->setRequired(true); - $definition->setTableName('list_attributes'); $manager->persist($definition); diff --git a/tests/Unit/Subscription/Request/PublicSubscriptionRequestTest.php b/tests/Unit/Subscription/Request/PublicSubscriptionRequestTest.php new file mode 100644 index 00000000..e9ee6384 --- /dev/null +++ b/tests/Unit/Subscription/Request/PublicSubscriptionRequestTest.php @@ -0,0 +1,45 @@ +email = ' test@example.com '; + + $dto = $request->getDto(); + + $this->assertSame($request, $dto); + $this->assertSame('test@example.com', $dto->email); + } + + public function testGetDtoKeepsNullEmail(): void + { + $request = new PublicSubscriptionRequest(); + + $dto = $request->getDto(); + + $this->assertSame($request, $dto); + $this->assertNull($dto->email); + $this->assertSame([], $dto->attributes); + } + + public function testSetSubscribePageStoresAndReturnsSelf(): void + { + $request = new PublicSubscriptionRequest(); + $subscribePage = new SubscribePage(); + + $result = $request->setSubscribePage($subscribePage); + + $this->assertSame($request, $result); + $this->assertSame($subscribePage, $request->getSubscribePage()); + } +} diff --git a/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicTest.php b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicTest.php new file mode 100644 index 00000000..d60449e2 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicTest.php @@ -0,0 +1,27 @@ +assertSame('strict', $constraint->mode); + $this->assertSame('Subscriber list with id "{{ value }}" does not exists.', $constraint->message); + } + + public function testConstructorAllowsOverridingValues(): void + { + $constraint = new ListExistsPublic(mode: 'relaxed', message: 'Custom message.'); + + $this->assertSame('relaxed', $constraint->mode); + $this->assertSame('Custom message.', $constraint->message); + } +} diff --git a/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php new file mode 100644 index 00000000..3c0c6652 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php @@ -0,0 +1,79 @@ +subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $context = $this->createMock(ExecutionContextInterface::class); + + $this->validator = new ListExistsPublicValidator($this->subscriberListRepository); + $this->validator->initialize($context); + } + + public function testValidateSkipsNull(): void + { + $this->subscriberListRepository->expects($this->never())->method('findBy'); + $this->validator->validate(null, new ListExistsPublic()); + $this->assertTrue(true); + } + + public function testValidateSkipsEmptyString(): void + { + $this->subscriberListRepository->expects($this->never())->method('findBy'); + $this->validator->validate('', new ListExistsPublic()); + $this->assertTrue(true); + } + + public function testValidateThrowsUnexpectedTypeException(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate(123, $this->createMock(Constraint::class)); + } + + public function testValidateThrowsNotFoundExceptionIfListDoesNotExist(): void + { + $this->subscriberListRepository + ->expects($this->once()) + ->method('findBy') + ->with(['id' => 123, 'public' => true]) + ->willReturn([]); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Subscriber list does not exists.'); + + $this->validator->validate('123', new ListExistsPublic()); + } + + public function testValidatePassesIfPublicListExists(): void + { + $subscriberList = $this->createMock(SubscriberList::class); + + $this->subscriberListRepository + ->expects($this->once()) + ->method('findBy') + ->with(['id' => 123, 'public' => true]) + ->willReturn([$subscriberList]); + + $this->validator->validate('123', new ListExistsPublic()); + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionTest.php b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionTest.php new file mode 100644 index 00000000..c6806129 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionTest.php @@ -0,0 +1,38 @@ +assertSame('This attribute is required.', $constraint->requiredAttributeMessage); + $this->assertSame('Invalid value.', $constraint->invalidValueMessage); + $this->assertSame('Unknown attribute.', $constraint->unknownAttributeMessage); + $this->assertSame('Invalid email address.', $constraint->invalidEmailMessage); + $this->assertTrue($constraint->rejectUnknownAttributes); + } + + public function testGetTargetsReturnsClassConstraint(): void + { + $constraint = new ValidPublicSubscription(); + + $this->assertSame(Constraint::CLASS_CONSTRAINT, $constraint->getTargets()); + } + + public function testValidatedByReturnsValidatorClass(): void + { + $constraint = new ValidPublicSubscription(); + + $this->assertSame(ValidPublicSubscriptionValidator::class, $constraint->validatedBy()); + } +} From fec65e4dbdf6783a2e48b344697d1ad963da59f6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Jun 2026 16:55:24 +0400 Subject: [PATCH 15/18] Add unsubscribe endpoint --- .../SubscribePagePublicController.php | 94 ++++++++++++++++--- .../Request/PublicSubscriptionRequest.php | 1 + .../SubscribePagePublicControllerTest.php | 5 +- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/Subscription/Controller/SubscribePagePublicController.php b/src/Subscription/Controller/SubscribePagePublicController.php index 556116c6..d5085bc9 100644 --- a/src/Subscription/Controller/SubscribePagePublicController.php +++ b/src/Subscription/Controller/SubscribePagePublicController.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Subscription\Model\SubscribePageData; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; @@ -15,7 +16,6 @@ use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Subscription\Request\PublicSubscriptionRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; -use PhpList\RestBundle\Subscription\Serializer\SubscriptionNormalizer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -30,7 +30,6 @@ public function __construct( private readonly SubscribePageManager $subscribePageManager, private readonly EntityManagerInterface $entityManager, private readonly SubscriptionManager $subscriptionManager, - private readonly SubscriptionNormalizer $subscriptionNormalizer, private readonly SubscriberAttributeManager $subscriberAttributeManager, private readonly SubscriberListRepository $subscriberListRepository, ) { @@ -99,12 +98,8 @@ public function getPublicPage(Request $request, SubscribePagePublicNormalizer $n ], responses: [ new OA\Response( - response: 201, - description: 'Success', - content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/Subscription') - ) + response: 204, + description: 'Success' ), new OA\Response( response: 400, @@ -127,7 +122,7 @@ public function subscribe(Request $request, int $pageId): JsonResponse { $page = $this->subscribePageManager->findPublicPage(id: $pageId); if (!$page) { - throw $this->createNotFoundException('Subscriber subscribe page not found.'); + throw $this->createNotFoundException('Subscribe page not found.'); } /** @var PublicSubscriptionRequest $subscriptionRequest */ @@ -158,8 +153,85 @@ public function subscribe(Request $request, int $pageId): JsonResponse } $this->entityManager->flush(); - $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + #[Route('/{pageId}', name: 'unsubscribe', methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/public/subscribe-pages/{pageId}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.' . + 'Unsubscribe subscriber from a list from subscribe page.', + summary: 'Delete subscription', + tags: ['subscribe-pages'], + parameters: [ + new OA\Parameter( + name: 'pageId', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + new OA\Parameter( + name: 'email', + description: 'Subscriber email', + in: 'query', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 204, + description: 'Success' + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function unsubscribe(Request $request, int $pageId): JsonResponse + { + $page = $this->subscribePageManager->findPage(id: $pageId); + if (!$page) { + throw $this->createNotFoundException('Subscribe page not found.'); + } + + /** @var SubscribePageData|null $listsField */ + $listsField = array_find( + $page->getData(), + fn (SubscribePageData $data) => $data->getName() === 'lists' + ); + + if ($listsField === null) { + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + $listsIds = explode(',', $listsField->getData() ?? ''); + if ($listsIds == []) { + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + $lists = $this->subscriberListRepository->findBy(['id' => $listsIds]); + foreach ($lists as $list) { + $this->subscriptionManager->deleteSubscriptions( + subscriberList: $list, + emails: [$request->query->get('email')] + ); + } + $this->entityManager->flush(); - return $this->json($normalized, Response::HTTP_CREATED); + return $this->json(null, Response::HTTP_NO_CONTENT); } } diff --git a/src/Subscription/Request/PublicSubscriptionRequest.php b/src/Subscription/Request/PublicSubscriptionRequest.php index 3c9e3fb1..6fef6ca6 100644 --- a/src/Subscription/Request/PublicSubscriptionRequest.php +++ b/src/Subscription/Request/PublicSubscriptionRequest.php @@ -14,6 +14,7 @@ #[OA\Schema( schema: 'PublicSubscriptionRequest', + required: ['email', 'list_id'], properties: [ new OA\Property( property: 'email', diff --git a/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php index 797d2f84..010c2b7b 100644 --- a/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php @@ -89,10 +89,7 @@ public function testSubscribeCreatesSubscriptionAndAttributes(): void ], JSON_THROW_ON_ERROR); $this->jsonRequest('POST', '/api/v2/public/subscribe-pages/1', [], [], [], $payload); - $this->assertHttpCreated(); - - $response = $this->getDecodedJsonResponseContent(); - self::assertSame('public@example.com', $response[0]['subscriber']['email'] ?? null); + $this->assertHttpNoContent(); $subscriber = $this->entityManager?->getRepository(Subscriber::class) ->findOneBy(['email' => 'public@example.com']); From 1bd7755039e3e8513ba46be160d964eee401d0e4 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 10 Jun 2026 15:43:56 +0400 Subject: [PATCH 16/18] Add PublicUnsubscriptionRequest class for unsubscribe functionality in SubscribePagePublicController --- .../Controller/SubscribePageController.php | 2 +- .../SubscribePagePublicController.php | 23 ++++++----- .../Request/PublicUnsubscriptionRequest.php | 38 +++++++++++++++++++ 3 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 src/Subscription/Request/PublicUnsubscriptionRequest.php diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index 422768d3..edfcac56 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -200,7 +200,7 @@ public function createPage(Request $request): JsonResponse name: 'php-auth-pw', description: 'Session key obtained from login', in: 'header', - required: false, + required: true, schema: new OA\Schema(type: 'string') ), new OA\Parameter( diff --git a/src/Subscription/Controller/SubscribePagePublicController.php b/src/Subscription/Controller/SubscribePagePublicController.php index d5085bc9..a296c2bd 100644 --- a/src/Subscription/Controller/SubscribePagePublicController.php +++ b/src/Subscription/Controller/SubscribePagePublicController.php @@ -15,6 +15,7 @@ use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; use PhpList\RestBundle\Subscription\Request\PublicSubscriptionRequest; +use PhpList\RestBundle\Subscription\Request\PublicUnsubscriptionRequest; use PhpList\RestBundle\Subscription\Serializer\SubscribePagePublicNormalizer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -160,8 +161,13 @@ public function subscribe(Request $request, int $pageId): JsonResponse #[OA\Delete( path: '/api/v2/public/subscribe-pages/{pageId}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.' . - 'Unsubscribe subscriber from a list from subscribe page.', + 'Unsubscribe subscriber from lists of subscribe page.', summary: 'Delete subscription', + requestBody: new OA\RequestBody( + description: '', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/PublicUnsubscriptionRequest') + ), tags: ['subscribe-pages'], parameters: [ new OA\Parameter( @@ -171,13 +177,6 @@ public function subscribe(Request $request, int $pageId): JsonResponse required: true, schema: new OA\Schema(type: 'integer') ), - new OA\Parameter( - name: 'email', - description: 'Subscriber email', - in: 'query', - required: true, - schema: new OA\Schema(type: 'string') - ), ], responses: [ new OA\Response( @@ -223,11 +222,17 @@ public function unsubscribe(Request $request, int $pageId): JsonResponse return $this->json(null, Response::HTTP_NO_CONTENT); } + /** @var PublicUnsubscriptionRequest $unsubscribeRequest */ + $unsubscribeRequest = $this->validator->validate( + request: $request, + dtoClass: PublicUnsubscriptionRequest::class + ); + $lists = $this->subscriberListRepository->findBy(['id' => $listsIds]); foreach ($lists as $list) { $this->subscriptionManager->deleteSubscriptions( subscriberList: $list, - emails: [$request->query->get('email')] + emails: [$unsubscribeRequest->email] ); } $this->entityManager->flush(); diff --git a/src/Subscription/Request/PublicUnsubscriptionRequest.php b/src/Subscription/Request/PublicUnsubscriptionRequest.php new file mode 100644 index 00000000..b81c2672 --- /dev/null +++ b/src/Subscription/Request/PublicUnsubscriptionRequest.php @@ -0,0 +1,38 @@ +email !== null) { + $this->email = trim($this->email); + } + + return $this; + } +} From edb22e42aed169de2fe381a7d7f7954e837542c0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 16 Jun 2026 11:02:52 +0400 Subject: [PATCH 17/18] Feat: ConfigController --- composer.json | 5 + config/services/controllers.yml | 7 + config/services/validators.yml | 5 + .../Controller/ConfigController.php | 346 ++++++++++++++++++ src/Configuration/Request/ConfigRequest.php | 53 +++ .../Request/UpdateConfigRequest.php | 31 ++ .../Serializer/ConfigNormalizer.php | 51 +++ .../Validator/Constraint/UniqueConfigKey.php | 13 + .../Constraint/UniqueConfigKeyValidator.php | 39 ++ tests/Integration/Composer/ScriptsTest.php | 1 + .../UniqueConfigKeyValidatorTest.php | 87 +++++ 11 files changed, 638 insertions(+) create mode 100644 src/Configuration/Controller/ConfigController.php create mode 100644 src/Configuration/Request/ConfigRequest.php create mode 100644 src/Configuration/Request/UpdateConfigRequest.php create mode 100644 src/Configuration/Serializer/ConfigNormalizer.php create mode 100644 src/Configuration/Validator/Constraint/UniqueConfigKey.php create mode 100644 src/Configuration/Validator/Constraint/UniqueConfigKeyValidator.php create mode 100644 tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php diff --git a/composer.json b/composer.json index 477d302b..443f5c96 100644 --- a/composer.json +++ b/composer.json @@ -126,6 +126,11 @@ "type": "attribute", "prefix": "/api/v2" }, + "rest-api-configuration": { + "resource": "@PhpListRestBundle/Configuration/Controller/", + "type": "attribute", + "prefix": "/api/v2" + }, "rest-api-analitics": { "resource": "@PhpListRestBundle/Statistics/Controller/", "type": "attribute", diff --git a/config/services/controllers.yml b/config/services/controllers.yml index 9f7566ea..57532cf6 100644 --- a/config/services/controllers.yml +++ b/config/services/controllers.yml @@ -25,6 +25,13 @@ services: autoconfigure: true public: true + PhpList\RestBundle\Configuration\Controller\: + resource: '../src/Configuration/Controller' + tags: [ 'controller.service_arguments' ] + autowire: true + autoconfigure: true + public: true + PhpList\RestBundle\Statistics\Controller\: resource: '../src/Statistics/Controller' tags: [ 'controller.service_arguments' ] diff --git a/config/services/validators.yml b/config/services/validators.yml index 2c45805d..ba0b8826 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -37,6 +37,11 @@ services: autoconfigure: true tags: [ 'validator.constraint_validator' ] + PhpList\RestBundle\Configuration\Validator\Constraint\UniqueConfigKeyValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + PhpList\RestBundle\Subscription\Validator\Constraint\ListExistsValidator: autowire: true autoconfigure: true diff --git a/src/Configuration/Controller/ConfigController.php b/src/Configuration/Controller/ConfigController.php new file mode 100644 index 00000000..ff20eb77 --- /dev/null +++ b/src/Configuration/Controller/ConfigController.php @@ -0,0 +1,346 @@ +denyUnlessSettingsAdmin($request, 'You are not allowed to view configuration.'); + $items = $this->manager->getAllEditable(); + + usort( + $items, + fn (Config $aConf, Config $bConf): int => strcmp( + strtolower($aConf->getKey()), + strtolower($bConf->getKey()) + ) + ); + + $count = count($items); + + return $this->json( + data: [ + 'items' => array_map(fn($config) => $this->normalizer->normalize($config), $items), + 'pagination' => [ + 'total' => $count, + 'limit' => $count + 1, + 'has_more' => false, + 'next_cursor' => null + ] + ], + status: Response::HTTP_OK + ); + } + + #[Route('/{key}', name: 'get_one', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/config/{key}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns one configuration item by key.', + summary: 'Gets a configuration item.', + tags: ['config'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'key', + description: 'Configuration key', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Config') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function getOne( + Request $request, + #[MapEntity(mapping: ['key' => 'key'])] ?Config $config, + ): JsonResponse { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to view configuration.'); + if ($config === null) { + throw $this->createNotFoundException('Configuration item not found.'); + } + + return $this->json($this->normalizer->normalize($config), Response::HTTP_OK); + } + + #[Route('', name: 'create', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/config', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Creates a configuration item.', + summary: 'Creates a configuration item.', + requestBody: new OA\RequestBody( + description: 'Configuration item data', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ConfigRequest') + ), + tags: ['config'], + 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/Config') + ), + 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 create(Request $request): JsonResponse + { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to create configuration.'); + /* @var ConfigRequest $configRequest */ + $configRequest = $this->validator->validate($request, ConfigRequest::class); + + $config = $this->manager->create($configRequest->getDto()); + $this->entityManager->flush(); + + return $this->json($this->normalizer->normalize($config), Response::HTTP_CREATED); + } + + #[Route('/{key}', name: 'update', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['PUT'])] + #[OA\Put( + path: '/api/v2/config/{key}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Updates a configuration item value.', + summary: 'Updates a configuration item.', + requestBody: new OA\RequestBody( + description: 'Configuration item data', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ConfigUpdateRequest') + ), + tags: ['config'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'key', + description: 'Configuration key', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Config') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function update( + Request $request, + #[MapEntity(mapping: ['key' => 'key'])] ?Config $config = null + ): JsonResponse { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to update configuration.'); + if ($config === null) { + throw $this->createNotFoundException('Configuration item not found.'); + } + /* @var UpdateConfigRequest $dto */ + $dto = $this->validator->validate($request, UpdateConfigRequest::class); + + try { + $this->manager->update($config, $dto->getDto()['value']); + } catch (ConfigNotEditableException $exception) { + throw $this->createAccessDeniedException($exception->getMessage()); + } + $this->entityManager->flush(); + + return $this->json($this->normalizer->normalize($config), Response::HTTP_OK); + } + + #[Route('/{key}', name: 'delete', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/config/{key}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Deletes a configuration item.', + summary: 'Deletes a configuration item.', + tags: ['config'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'key', + description: 'Configuration key', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: Response::HTTP_NO_CONTENT, description: 'Success'), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function delete( + Request $request, + #[MapEntity(mapping: ['key' => 'key'])] ?Config $config = null + ): JsonResponse { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to delete configuration.'); + if ($config === null) { + throw $this->createNotFoundException('Configuration item not found.'); + } + + $this->manager->delete($config); + $this->entityManager->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + private function denyUnlessSettingsAdmin(Request $request, string $message): void + { + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Settings)) { + throw $this->createAccessDeniedException($message); + } + } +} diff --git a/src/Configuration/Request/ConfigRequest.php b/src/Configuration/Request/ConfigRequest.php new file mode 100644 index 00000000..843cddf1 --- /dev/null +++ b/src/Configuration/Request/ConfigRequest.php @@ -0,0 +1,53 @@ +key, + $this->value, + $this->editable, + $this->type + ); + } +} diff --git a/src/Configuration/Request/UpdateConfigRequest.php b/src/Configuration/Request/UpdateConfigRequest.php new file mode 100644 index 00000000..671be899 --- /dev/null +++ b/src/Configuration/Request/UpdateConfigRequest.php @@ -0,0 +1,31 @@ + $this->value, + ]; + } +} diff --git a/src/Configuration/Serializer/ConfigNormalizer.php b/src/Configuration/Serializer/ConfigNormalizer.php new file mode 100644 index 00000000..7ffa6975 --- /dev/null +++ b/src/Configuration/Serializer/ConfigNormalizer.php @@ -0,0 +1,51 @@ + $object->getKey(), + 'value' => $object->getValue(), + 'editable' => $object->isEditable(), + 'type' => $object->getType(), + ]; + } + + /** + * Checks whether the value can be normalized. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Config; + } +} diff --git a/src/Configuration/Validator/Constraint/UniqueConfigKey.php b/src/Configuration/Validator/Constraint/UniqueConfigKey.php new file mode 100644 index 00000000..a2ec7c33 --- /dev/null +++ b/src/Configuration/Validator/Constraint/UniqueConfigKey.php @@ -0,0 +1,13 @@ +entityManager->find(Config::class, $value) instanceof Config) { + throw new ConflictHttpException($constraint->message); + } + } +} diff --git a/tests/Integration/Composer/ScriptsTest.php b/tests/Integration/Composer/ScriptsTest.php index 436b4d16..5b0df800 100644 --- a/tests/Integration/Composer/ScriptsTest.php +++ b/tests/Integration/Composer/ScriptsTest.php @@ -123,6 +123,7 @@ public static function moduleRoutingDataProvider(): array 'identity' => ["resource: '@PhpListRestBundle/Identity/Controller/'"], 'messaging' => ["resource: '@PhpListRestBundle/Messaging/Controller/'"], 'subscription' => ["resource: '@PhpListRestBundle/Subscription/Controller/'"], + 'configuration' => ["resource: '@PhpListRestBundle/Configuration/Controller/'"], 'type' => ['type: attribute'], ]; } diff --git a/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php b/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php new file mode 100644 index 00000000..ae9cb6a3 --- /dev/null +++ b/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php @@ -0,0 +1,87 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->validator = new UniqueConfigKeyValidator($this->entityManager); + } + + public function testValidateSkipsNull(): void + { + $this->entityManager->expects(self::never())->method('find'); + + $this->validator->validate(null, new UniqueConfigKey()); + + self::assertTrue(true); + } + + public function testValidateSkipsEmptyString(): void + { + $this->entityManager->expects(self::never())->method('find'); + + $this->validator->validate('', new UniqueConfigKey()); + + self::assertTrue(true); + } + + public function testValidateThrowsUnexpectedTypeException(): void + { + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate('organisation_name', $this->createMock(Constraint::class)); + } + + public function testValidateThrowsUnexpectedValueException(): void + { + $this->expectException(UnexpectedValueException::class); + + $this->validator->validate(123, new UniqueConfigKey()); + } + + public function testValidateThrowsConflictHttpExceptionIfConfigKeyExists(): void + { + $this->entityManager + ->expects(self::once()) + ->method('find') + ->with(Config::class, 'organisation_name') + ->willReturn(new Config()); + + $this->expectException(ConflictHttpException::class); + $this->expectExceptionMessage('Configuration item already exists.'); + + $this->validator->validate('organisation_name', new UniqueConfigKey()); + } + + public function testValidatePassesIfConfigKeyIsUnique(): void + { + $this->entityManager + ->expects(self::once()) + ->method('find') + ->with(Config::class, 'new_config_key') + ->willReturn(null); + + $this->validator->validate('new_config_key', new UniqueConfigKey()); + + self::assertTrue(true); + } +} From 388965e23e4bf29afc6f23c9668a4915436e2c5d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 16 Jun 2026 17:46:56 +0400 Subject: [PATCH 18/18] Feat: ConfigController tests --- .../Controller/ConfigController.php | 26 +- ...figRequest.php => CreateConfigRequest.php} | 10 +- .../Controller/ConfigControllerTest.php | 229 ++++++++++++++++++ .../Request/CreateConfigRequestTest.php | 200 +++++++++++++++ .../Request/UpdateConfigRequestTest.php | 69 ++++++ .../Serializer/ConfigNormalizerTest.php | 80 ++++++ 6 files changed, 596 insertions(+), 18 deletions(-) rename src/Configuration/Request/{ConfigRequest.php => CreateConfigRequest.php} (88%) create mode 100644 tests/Integration/Configuration/Controller/ConfigControllerTest.php create mode 100644 tests/Unit/Configuration/Request/CreateConfigRequestTest.php create mode 100644 tests/Unit/Configuration/Request/UpdateConfigRequestTest.php create mode 100644 tests/Unit/Configuration/Serializer/ConfigNormalizerTest.php diff --git a/src/Configuration/Controller/ConfigController.php b/src/Configuration/Controller/ConfigController.php index ff20eb77..52b5afe1 100644 --- a/src/Configuration/Controller/ConfigController.php +++ b/src/Configuration/Controller/ConfigController.php @@ -13,7 +13,7 @@ use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; -use PhpList\RestBundle\Configuration\Request\ConfigRequest; +use PhpList\RestBundle\Configuration\Request\CreateConfigRequest; use PhpList\RestBundle\Configuration\Request\UpdateConfigRequest; use PhpList\RestBundle\Configuration\Serializer\ConfigNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -37,11 +37,11 @@ public function __construct( #[Route('', name: 'get_list', methods: ['GET'])] #[OA\Get( - path: '/api/v2/config', + path: '/api/v2/configs', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Returns all configuration items.', summary: 'Gets all configuration items.', - tags: ['config'], + tags: ['configs'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -105,11 +105,11 @@ public function list(Request $request): JsonResponse #[Route('/{key}', name: 'get_one', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['GET'])] #[OA\Get( - path: '/api/v2/config/{key}', + path: '/api/v2/configs/{key}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Returns one configuration item by key.', summary: 'Gets a configuration item.', - tags: ['config'], + tags: ['configs'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -158,7 +158,7 @@ public function getOne( #[Route('', name: 'create', methods: ['POST'])] #[OA\Post( - path: '/api/v2/config', + path: '/api/v2/configs', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Creates a configuration item.', summary: 'Creates a configuration item.', @@ -167,7 +167,7 @@ public function getOne( required: true, content: new OA\JsonContent(ref: '#/components/schemas/ConfigRequest') ), - tags: ['config'], + tags: ['configs'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -203,8 +203,8 @@ public function getOne( public function create(Request $request): JsonResponse { $this->denyUnlessSettingsAdmin($request, 'You are not allowed to create configuration.'); - /* @var ConfigRequest $configRequest */ - $configRequest = $this->validator->validate($request, ConfigRequest::class); + /* @var CreateConfigRequest $configRequest */ + $configRequest = $this->validator->validate($request, CreateConfigRequest::class); $config = $this->manager->create($configRequest->getDto()); $this->entityManager->flush(); @@ -214,7 +214,7 @@ public function create(Request $request): JsonResponse #[Route('/{key}', name: 'update', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['PUT'])] #[OA\Put( - path: '/api/v2/config/{key}', + path: '/api/v2/configs/{key}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Updates a configuration item value.', summary: 'Updates a configuration item.', @@ -223,7 +223,7 @@ public function create(Request $request): JsonResponse required: true, content: new OA\JsonContent(ref: '#/components/schemas/ConfigUpdateRequest') ), - tags: ['config'], + tags: ['configs'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -286,11 +286,11 @@ public function update( #[Route('/{key}', name: 'delete', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['DELETE'])] #[OA\Delete( - path: '/api/v2/config/{key}', + path: '/api/v2/configs/{key}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 'Deletes a configuration item.', summary: 'Deletes a configuration item.', - tags: ['config'], + tags: ['configs'], parameters: [ new OA\Parameter( name: 'php-auth-pw', diff --git a/src/Configuration/Request/ConfigRequest.php b/src/Configuration/Request/CreateConfigRequest.php similarity index 88% rename from src/Configuration/Request/ConfigRequest.php rename to src/Configuration/Request/CreateConfigRequest.php index 843cddf1..766a926a 100644 --- a/src/Configuration/Request/ConfigRequest.php +++ b/src/Configuration/Request/CreateConfigRequest.php @@ -21,7 +21,7 @@ ], type: 'object' )] -class ConfigRequest implements RequestInterface +class CreateConfigRequest implements RequestInterface { #[Assert\NotBlank] #[Assert\Type('string')] @@ -44,10 +44,10 @@ class ConfigRequest implements RequestInterface public function getDto(): CreateConfigDto { return new CreateConfigDto( - $this->key, - $this->value, - $this->editable, - $this->type + key: $this->key, + value: $this->value, + editable: $this->editable, + type: $this->type ); } } diff --git a/tests/Integration/Configuration/Controller/ConfigControllerTest.php b/tests/Integration/Configuration/Controller/ConfigControllerTest.php new file mode 100644 index 00000000..3602987c --- /dev/null +++ b/tests/Integration/Configuration/Controller/ConfigControllerTest.php @@ -0,0 +1,229 @@ +get(ConfigController::class)); + } + + public function testListWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/configs'); + $this->assertHttpForbidden(); + } + + public function testListWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/configs', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testListWithValidSessionKeyReturnsOkayWithPaginationStructure(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/configs'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertIsArray($response); + self::assertArrayHasKey('items', $response); + self::assertArrayHasKey('pagination', $response); + } + + public function testListReturnsCreatedConfigData(): void + { + $config = new Config(); + $config->setKey('organisation_name'); + $config->setValue('Example Organisation'); + $config->setEditable(true); + $config->setType('text'); + + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('GET', '/api/v2/configs'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertNotEmpty($response['items']); + + $found = false; + foreach ($response['items'] as $item) { + if ($item['key'] === 'organisation_name') { + $found = true; + self::assertSame('Example Organisation', $item['value']); + self::assertTrue($item['editable']); + self::assertSame('text', $item['type']); + } + } + + self::assertTrue($found, 'Created config item not found in list response.'); + } + + public function testGetOneWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/configs/organisation_name'); + $this->assertHttpForbidden(); + } + + public function testGetOneWithValidSessionReturnsOkayAndData(): void + { + $config = new Config(); + $config->setKey('site_title'); + $config->setValue('My Site'); + $config->setEditable(true); + + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('GET', '/api/v2/configs/site_title'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('site_title', $response['key']); + self::assertSame('My Site', $response['value']); + self::assertTrue($response['editable']); + } + + public function testGetOneWithInvalidKeyReturnsNotFound(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/configs/nonexistent_key'); + $this->assertHttpNotFound(); + } + + public function testCreateWithoutSessionKeyReturnsForbidden(): void + { + $json = json_encode(['key' => 'new_key', 'value' => 'val']); + $this->jsonRequest('POST', '/api/v2/configs', [], [], [], $json); + $this->assertHttpForbidden(); + } + + public function testCreateWithValidSessionCreatesConfig(): void + { + $json = json_encode(['key' => 'new_key', 'value' => 'val', 'editable' => true, 'type' => 'text']); + $this->authenticatedJsonRequest('POST', '/api/v2/configs', [], [], [], $json); + + $this->assertHttpCreated(); + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('new_key', $response['key']); + self::assertSame('val', $response['value']); + self::assertTrue($response['editable']); + self::assertSame('text', $response['type']); + } + + public function testCreateWithDuplicateKeyReturnsConflict(): void + { + $config = new Config(); + $config->setKey('dup_key'); + $config->setValue('first'); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $json = json_encode(['key' => 'dup_key', 'value' => 'second']); + $this->authenticatedJsonRequest('POST', '/api/v2/configs', [], [], [], $json); + + $this->assertHttpConflict(); + } + + public function testUpdateWithoutSessionKeyReturnsForbidden(): void + { + $this->jsonRequest('PUT', '/api/v2/configs/some_key', [], [], [], json_encode(['value' => 'x'])); + $this->assertHttpForbidden(); + } + + public function testUpdateWithValidSessionUpdatesValue(): void + { + $config = new Config(); + $config->setKey('up_key'); + $config->setValue('old'); + $config->setEditable(true); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('PUT', '/api/v2/configs/up_key', [], [], [], json_encode(['value' => 'new'])); + + $this->assertHttpOkay(); + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('new', $response['value']); + } + + public function testUpdateForNonexistentKeyReturnsNotFound(): void + { + $this->authenticatedJsonRequest( + 'PUT', + '/api/v2/configs/does_not_exist', + [], + [], + [], + json_encode(['value' => 'x']) + ); + $this->assertHttpNotFound(); + } + + public function testUpdateNonEditableReturnsForbidden(): void + { + $config = new Config(); + $config->setKey('locked_key'); + $config->setValue('orig'); + $config->setEditable(false); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest( + 'PUT', + '/api/v2/configs/locked_key', + [], + [], + [], + json_encode(['value' => 'changed']) + ); + + $this->assertHttpForbidden(); + } + + public function testDeleteWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('DELETE', '/api/v2/configs/some_key'); + $this->assertHttpForbidden(); + } + + public function testDeleteWithValidSessionDeletesConfig(): void + { + $config = new Config(); + $config->setKey('del_key'); + $config->setValue('to delete'); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/configs/del_key'); + $this->assertHttpNoContent(); + + $this->entityManager->clear(); + self::assertNull($this->entityManager->getRepository(Config::class)->find('del_key')); + } + + public function testDeleteNonexistentReturnsNotFound(): void + { + $this->authenticatedJsonRequest('DELETE', '/api/v2/configs/unknown_key'); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Unit/Configuration/Request/CreateConfigRequestTest.php b/tests/Unit/Configuration/Request/CreateConfigRequestTest.php new file mode 100644 index 00000000..a806ba06 --- /dev/null +++ b/tests/Unit/Configuration/Request/CreateConfigRequestTest.php @@ -0,0 +1,200 @@ +key = 'organisation_name'; + $request->value = 'Example Organisation'; + + self::assertInstanceOf(RequestInterface::class, $request); + } + + public function testEditableDefaultsToTrue(): void + { + $request = new CreateConfigRequest(); + + self::assertTrue($request->editable); + } + + public function testTypeDefaultsToNull(): void + { + $request = new CreateConfigRequest(); + + self::assertNull($request->type); + } + + public function testGetDtoReturnsCreateConfigDtoWithExpectedValues(): void + { + $request = new CreateConfigRequest(); + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = false; + $request->type = 'text'; + + $dto = $request->getDto(); + + self::assertInstanceOf(CreateConfigDto::class, $dto); + self::assertSame('organisation_name', $dto->key); + self::assertSame('Example Organisation', $dto->value); + self::assertFalse($dto->editable); + self::assertSame('text', $dto->type); + } + + public function testGetDtoUsesDefaultsWhenNotExplicitlySet(): void + { + $request = new CreateConfigRequest(); + $request->key = 'site_title'; + $request->value = 'My Site'; + + $dto = $request->getDto(); + + self::assertTrue($dto->editable); + self::assertNull($dto->type); + } + + public function testGetDtoReturnsCreateConfigDto(): void + { + $request = new CreateConfigRequest(); + + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = true; + $request->type = 'text'; + + $dto = $request->getDto(); + + $this->assertInstanceOf(CreateConfigDto::class, $dto); + } + + public function testGetDtoMapsAllProperties(): void + { + $request = new CreateConfigRequest(); + + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = false; + $request->type = 'text'; + + $dto = $request->getDto(); + + $this->assertSame('organisation_name', $dto->key); + $this->assertSame('Example Organisation', $dto->value); + $this->assertFalse($dto->editable); + $this->assertSame('text', $dto->type); + } + + public function testGetDtoWithNullType(): void + { + $request = new CreateConfigRequest(); + + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = true; + $request->type = null; + + $dto = $request->getDto(); + + $this->assertNull($dto->type); + } + + public function testKeyFailsWhenBlank(): void + { + $violations = $this->createValidator()->validate('', [new NotBlank()]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testKeyFailsWhenExceedsMaxLength(): void + { + $longKey = str_repeat('a', 36); + + $request = new CreateConfigRequest(); + $request->key = $longKey; + $request->value = 'value'; + + $validator = $this->createValidator(); + $violations = $validator->validate($request->key, [ + new Length(max: 35), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testKeyFailsWithInvalidCharacters(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate('invalid key!', [ + new Regex(pattern: '/^[A-Za-z0-9_.:-]+$/'), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testKeyPassesWithAllowedCharacters(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate('organisation_name.v1:test-2', [ + new Regex(pattern: '/^[A-Za-z0-9_.:-]+$/'), + ]); + + self::assertCount(0, $violations); + } + + public function testValueFailsWhenNotString(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate(123, [ + new Type('string'), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testTypeFailsWhenExceedsMaxLength(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate(str_repeat('a', 26), [ + new Length(max: 25), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testTypeAllowsNull(): void + { + $request = new CreateConfigRequest(); + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->type = null; + + self::assertNull($request->type); + } + + private function createValidator(): ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + } +} diff --git a/tests/Unit/Configuration/Request/UpdateConfigRequestTest.php b/tests/Unit/Configuration/Request/UpdateConfigRequestTest.php new file mode 100644 index 00000000..891aff4c --- /dev/null +++ b/tests/Unit/Configuration/Request/UpdateConfigRequestTest.php @@ -0,0 +1,69 @@ +value = 'Example Organisation'; + + self::assertSame(['value' => 'Example Organisation'], $request->getDto()); + } + + public function testValidationPassesForValidStringValue(): void + { + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + + $request = new UpdateConfigRequest(); + $request->value = 'Example Organisation'; + + $violations = $validator->validate($request); + + self::assertCount(0, $violations); + } + + public function testValidationFailsForEmptyStringIsActuallyValid(): void + { + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + + $request = new UpdateConfigRequest(); + $request->value = ''; + + $violations = $validator->validate($request); + + self::assertCount(0, $violations); + } + + public function testValidationFailsForNullString(): void + { + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + + $request = new UpdateConfigRequest(); + + $violations = $validator->validate($request); + + self::assertCount(1, $violations); + } + + public function testImplementsRequestInterface(): void + { + $request = new UpdateConfigRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + } +} diff --git a/tests/Unit/Configuration/Serializer/ConfigNormalizerTest.php b/tests/Unit/Configuration/Serializer/ConfigNormalizerTest.php new file mode 100644 index 00000000..7b2dd23b --- /dev/null +++ b/tests/Unit/Configuration/Serializer/ConfigNormalizerTest.php @@ -0,0 +1,80 @@ +normalizer = new ConfigNormalizer(); + } + + public function testSupportsNormalizationReturnsTrueForConfig(): void + { + $config = $this->createMock(Config::class); + + self::assertTrue($this->normalizer->supportsNormalization($config)); + } + + public function testSupportsNormalizationReturnsFalseForNonConfig(): void + { + self::assertFalse($this->normalizer->supportsNormalization(new stdClass())); + } + + public function testSupportsNormalizationReturnsFalseForNull(): void + { + self::assertFalse($this->normalizer->supportsNormalization(null)); + } + + public function testNormalizeReturnsExpectedArrayForConfig(): void + { + $config = $this->createMock(Config::class); + $config->method('getKey')->willReturn('organisation_name'); + $config->method('getValue')->willReturn('Example Organisation'); + $config->method('isEditable')->willReturn(true); + $config->method('getType')->willReturn('text'); + + $result = $this->normalizer->normalize($config); + + self::assertSame([ + 'key' => 'organisation_name', + 'value' => 'Example Organisation', + 'editable' => true, + 'type' => 'text', + ], $result); + } + + public function testNormalizeHandlesNullValueAndType(): void + { + $config = $this->createMock(Config::class); + $config->method('getKey')->willReturn('some_key'); + $config->method('getValue')->willReturn(null); + $config->method('isEditable')->willReturn(false); + $config->method('getType')->willReturn(null); + + $result = $this->normalizer->normalize($config); + + self::assertSame([ + 'key' => 'some_key', + 'value' => null, + 'editable' => false, + 'type' => null, + ], $result); + } + + public function testNormalizeReturnsEmptyArrayForNonConfigObject(): void + { + $result = $this->normalizer->normalize(new stdClass()); + + self::assertSame([], $result); + } +}