From 784bb28794e6947cd2deec1e1eff36fb38399977 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 30 Apr 2026 14:09:53 +0400 Subject: [PATCH 01/13] 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 3ab0794..30587aa 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/13] 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 b961c1e..33eee07 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 0000000..7a6d845 --- /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/13] 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 7a6d845..46d544d 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 92598e8..477d302 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 ef7a59c..1959dd8 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/13] Developer --- src/PhpListRestBundle.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpListRestBundle.php b/src/PhpListRestBundle.php index a856c86..dd20cbf 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/13] 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 1959dd8..61ce70a 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 16f3eee..31447f1 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 0000000..c22e6f5 --- /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 d58a663..702b648 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 fa2d541..9f43254 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 523e590..f579b62 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/13] 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 61ce70a..08ca702 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 44b6e5c..99996c0 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 9f43254..cf0c740 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/13] 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 46d544d..855f2f5 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 99996c0..e151dd6 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/13] 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 c22e6f5..eeef9f8 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/13] 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 855f2f5..0aac103 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/13] 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 08ca702..cbb6d8c 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 0000000..7862ccd --- /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 cf0c740..9f43254 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 0000000..d66fd5f --- /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/13] 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 7862ccd..dc95cda 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 d66fd5f..d864777 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/13] 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 1712757..b76fc91 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 8d68037..eabc05d 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 ddd648a..91694d2 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 cbb6d8c..2b64c1f 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 2a038f9..b67db97 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 0000000..4bc62fb --- /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 0000000..150b06d --- /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 0000000..7deea36 --- /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 9f43254..b6879e2 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 0000000..a0ea706 --- /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 0000000..8c980ad --- /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/13] 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 eabc05d..2c45805 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 2b64c1f..59fa1aa 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 0000000..c160c51 --- /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 e151dd6..44b6e5c 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 4bc62fb..e91181b 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 dc95cda..befdb84 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 150b06d..ff39195 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 0000000..9b8a00c --- /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 0000000..bfa4f95 --- /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 8cbcd21..4eff2f4 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 7deea36..18fec6b 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 b9651cd..6a8e1f1 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 b6879e2..c21b9ac 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 d864777..5c91d54 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 a0ea706..04aa38f 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 8c980ad..57a9b61 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()); } } -