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..0aac103 --- /dev/null +++ b/.github/workflows/front-docs.yml @@ -0,0 +1,128 @@ +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: + - 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 + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REF: ${{ github.head_ref }} + REF_NAME: ${{ github.ref_name }} + id: branch + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "source_branch=$HEAD_REF" >> "$GITHUB_OUTPUT" + else + echo "source_branch=$REF_NAME" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout Source Repository + uses: actions/checkout@v4 + + - 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/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." 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/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..2c45805 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -42,6 +42,16 @@ 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 + 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/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( 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', diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index ef7a59c..59fa1aa 100644 --- a/src/Subscription/Controller/SubscribePageController.php +++ b/src/Subscription/Controller/SubscribePageController.php @@ -6,13 +6,14 @@ 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; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -30,15 +31,16 @@ public function __construct( private readonly SubscribePageManager $subscribePageManager, private readonly SubscribePageNormalizer $normalizer, private readonly EntityManagerInterface $entityManager, + private readonly PaginatedDataProvider $paginatedProvider, ) { parent::__construct($authentication, $validator); } - #[Route('/{id}', name: 'get', requirements: ['id' => '\\d+'], methods: ['GET'])] + #[Route('/', name: 'get_all', methods: ['GET'])] #[OA\Get( - path: '/api/v2/subscribe-pages/{id}', + path: '/api/v2/subscribe-pages', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Get subscribe page', + summary: 'Get subscribe pages list', tags: ['subscriptions'], parameters: [ new OA\Parameter( @@ -49,18 +51,35 @@ public function __construct( 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') + 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(ref: '#/components/schemas/SubscribePage'), + 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, @@ -74,23 +93,25 @@ public function __construct( ), ] )] - public function getPage( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { + 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.'); } - if (!$page) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - return $this->json($this->normalizer->normalize($page), Response::HTTP_OK); + return $this->json( + $this->paginatedProvider->getPaginatedList( + request: $request, + normalizer: $this->normalizer, + className: SubscribePage::class, + filter: new PaginatedFilter(), + ), + 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.', @@ -101,6 +122,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 + ), ] ) ), @@ -143,32 +176,27 @@ 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); } - #[Route('/{id}', name: 'update', requirements: ['id' => '\\d+'], methods: ['PUT'])] - #[OA\Put( + #[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: 'Update subscribe page', - requestBody: new OA\RequestBody( - required: true, - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'title', type: 'string', nullable: true), - new OA\Property(property: 'active', type: 'boolean', nullable: true), - ] - ) - ), + summary: 'Get subscribe page', tags: ['subscriptions'], parameters: [ new OA\Parameter( 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( @@ -183,7 +211,7 @@ public function createPage(Request $request): JsonResponse new OA\Response( response: 200, description: 'Success', - content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage') + content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage'), ), new OA\Response( response: 403, @@ -197,93 +225,47 @@ public function createPage(Request $request): JsonResponse ), ] )] - public function updatePage( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { + public function getPage(Request $request): JsonResponse + { $admin = $this->requireAuthentication($request); if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to update subscribe pages.'); + 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'); } - /** @var SubscribePageRequest $updateRequest */ - $updateRequest = $this->validator->validate($request, SubscribePageRequest::class); - - $updated = $this->subscribePageManager->updatePage( - page: $page, - title: $updateRequest->title, - active: $updateRequest->active, - owner: $admin, - ); - $this->entityManager->flush(); - - return $this->json($this->normalizer->normalize($updated), Response::HTTP_OK); + return $this->json($this->normalizer->normalize($page), Response::HTTP_OK); } - #[Route('/{id}', name: 'delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])] - #[OA\Delete( + #[Route('/{id}', name: 'update', requirements: ['id' => '\\d+'], methods: ['PUT'])] + #[OA\Put( path: '/api/v2/subscribe-pages/{id}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: 'Delete subscribe page', - tags: ['subscriptions'], - parameters: [ - new OA\Parameter( - name: 'php-auth-pw', - description: 'Session key obtained from login', - in: 'header', - required: true, - schema: new OA\Schema(type: 'string') - ), - new OA\Parameter( - name: 'id', - description: 'Subscribe page ID', - in: 'path', - required: true, - schema: new OA\Schema(type: 'integer') - ) - ], - responses: [ - new OA\Response(response: 204, description: 'No Content'), - new OA\Response( - response: 403, - description: 'Failure', - content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') - ), - new OA\Response( - response: 404, - description: 'Not Found', - content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + summary: 'Update subscribe page', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'title', type: 'string', nullable: true), + new OA\Property(property: 'active', type: 'boolean', nullable: true), + 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 + ), + ] ) - ] - )] - public function deletePage( - Request $request, - #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null - ): JsonResponse { - $admin = $this->requireAuthentication($request); - if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to delete subscribe pages.'); - } - - if ($page === null) { - throw $this->createNotFoundException('Subscribe page not found'); - } - - $this->subscribePageManager->deletePage($page); - $this->entityManager->flush(); - - 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( @@ -305,17 +287,7 @@ public function deletePage( 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' - ) - ) + content: new OA\JsonContent(ref: '#/components/schemas/SubscribePage') ), new OA\Response( response: 403, @@ -326,49 +298,44 @@ public function deletePage( response: 404, description: 'Not Found', content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') - ) + ), ] )] - public function getPageData( + public function updatePage( Request $request, #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null ): JsonResponse { $admin = $this->requireAuthentication($request); if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to view subscribe page data.'); + throw $this->createAccessDeniedException('You are not allowed to update subscribe pages.'); } if (!$page) { throw $this->createNotFoundException('Subscribe page not found'); } - $data = $this->subscribePageManager->getPageData($page); + /** @var SubscribePageRequest $updateRequest */ + $updateRequest = $this->validator->validate($request, SubscribePageRequest::class); - $json = array_map(static function ($item) { - return [ - 'id' => $item->getId(), - 'name' => $item->getName(), - 'data' => $item->getData(), - ]; - }, $data); + $updated = $this->subscribePageManager->updatePage( + page: $page, + title: $updateRequest->title, + active: $updateRequest->active, + owner: $admin, + ); + if ($updateRequest->hasData()) { + $this->subscribePageManager->syncPageData(data: $updateRequest->getDataMap(), page: $page); + } + $this->entityManager->flush(); - return $this->json($json, Response::HTTP_OK); + return $this->json($this->normalizer->normalize($updated), Response::HTTP_OK); } - #[Route('/{id}/data', name: 'set_data', requirements: ['id' => '\\d+'], methods: ['PUT'])] - #[OA\Put( - path: '/api/v2/subscribe-pages/{id}/data', + #[Route('/{id}', name: 'delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/subscribe-pages/{id}', description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.', - summary: '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), - ] - ) - ), + summary: 'Delete subscribe page', tags: ['subscriptions'], parameters: [ new OA\Parameter( @@ -387,18 +354,7 @@ public function getPageData( ) ], 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: 204, description: 'No Content'), new OA\Response( response: 403, description: 'Failure', @@ -411,29 +367,22 @@ public function getPageData( ) ] )] - public function setPageData( + public function deletePage( Request $request, #[MapEntity(mapping: ['id' => 'id'])] ?SubscribePage $page = null ): JsonResponse { $admin = $this->requireAuthentication($request); if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { - throw $this->createAccessDeniedException('You are not allowed to update subscribe page data.'); + throw $this->createAccessDeniedException('You are not allowed to delete subscribe pages.'); } - if (!$page) { + if ($page === null) { 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->subscribePageManager->deletePage($page); $this->entityManager->flush(); - return $this->json([ - 'id' => $item->getId(), - 'name' => $item->getName(), - 'data' => $item->getData(), - ], Response::HTTP_OK); + return $this->json(null, Response::HTTP_NO_CONTENT); } } 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/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..e91181b --- /dev/null +++ b/src/Subscription/Request/PublicSubscriptionRequest.php @@ -0,0 +1,101 @@ + 'John', + 'lastname' => 'Grigoryan', + 'country' => 'Armenia', + ], + additionalProperties: true + ), + ] +)] +#[ValidPublicSubscription] +class PublicSubscriptionRequest implements RequestInterface +{ + #[Assert\NotBlank] + #[Assert\Email] + public ?string $email = null; + + #[ListExistsPublic] + #[Assert\Type(type: 'integer')] + public ?int $listId = 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/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..eeef9f8 --- /dev/null +++ b/src/Subscription/Serializer/SubscribePageDataNormalizer.php @@ -0,0 +1,46 @@ +getName() === 'attributes') { + $object->setData(trim(str_replace('+', ',', $object->getData()), ',')); + } + + return [ + 'key' => $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/src/Subscription/Serializer/SubscribePagePublicNormalizer.php b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php new file mode 100644 index 0000000..befdb84 --- /dev/null +++ b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php @@ -0,0 +1,114 @@ + $object->getId(), + 'title' => $object->getTitle(), + 'data' => array_reduce( + $object->getData(), + function (array $carry, SubscribePageData $data) { + $value = $data->getData(); + if ($data->getName() === 'attributes') { + $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; + }, + [] + ), + ]; + } + + 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; + } + + 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) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof SubscribePage; + } +} diff --git a/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php b/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php new file mode 100644 index 0000000..ff39195 --- /dev/null +++ b/src/Subscription/Service/PublicSubscriptionAttributeRuleProvider.php @@ -0,0 +1,90 @@ +, + * }> + */ + 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->subscribePageManager->extractLegacyOverrides($pageData); + + $rules = []; + foreach ($definitions as $definition) { + $id = $definition->getId(); + $override = $legacyOverrides[$id] ?? []; + $key = (new UnicodeString($definition->getName())) + ->snake() + ->lower() + ->toString(); + + $rules[$key] = [ + 'id' => $id, + 'key' => $key, + 'type' => $definition->getType(), + 'required' => array_key_exists('required', $override) + ? (bool) $override['required'] + : (bool) $definition->isRequired(), + 'allowed' => array_fill_keys(array_column($definition->getOptions(), 'id'), true), + ]; + } + + return $rules; + } + + /** + * @return array + */ + private function toMap(SubscribePage $page): array + { + $map = []; + foreach ($page->getData() as $item) { + $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))); + } +} 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 new file mode 100644 index 0000000..18fec6b --- /dev/null +++ b/src/Subscription/Validator/Constraint/ValidPublicSubscription.php @@ -0,0 +1,27 @@ +doesNotSupportValidation($value)) { + return; + } + + $rules = $this->ruleProvider->getRules($value->getSubscribePage()); + $submittedByKey = $this->mapSubmittedByKey($value); + $this->rejectUnknownAttributes($submittedByKey, $rules, $constraint); + + 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(); + } + } + } + + 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 + { + 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), + }; + } + + private function isValidScalarValue(mixed $value): bool + { + if (is_array($value) || is_object($value)) { + return false; + } + + return true; + } + + 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); + } + + private function isValidCheckboxGroupValue(mixed $value, mixed $allowed): bool + { + if (!is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (!isset($allowed[(string) $item])) { + return false; + } + } + + return true; + } + + private function isValidDateValue(mixed $value): bool + { + if (is_array($value)) { + return $this->isValidDateArray($value); + } + + $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 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)) { + return $value; + } + + if (is_numeric($value)) { + return (int) $value === 1; + } + + 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 fa2d541..c21b9ac 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 { @@ -70,6 +75,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 +91,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 +119,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 +157,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); @@ -186,107 +222,43 @@ public function testDeleteSubscribePageWithSessionNotFound(): void $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 + public function testPublicSubscribeCreatesSubscriptionAndAttributes(): void { $this->loadFixtures([ AdministratorFixture::class, - AdministratorTokenFixture::class, SubscribePageFixture::class, + SubscriberListFixture::class, + SubscriberAttributeDefinitionFixture::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, + 'email' => 'public@example.com', + 'confirmEmail' => 'public@example.com', + 'attributes' => [ + 'Country' => 'Armenia', + ], ]); - $payload = json_encode([ - 'value' => 'Hello world', - ], JSON_THROW_ON_ERROR); - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/1/data', content: $payload); - $this->assertHttpUnprocessableEntity(); - } + $this->jsonRequest('POST', '/api/v2/subscribe-pages/1/lists/1/subscribers', [], [], [], $payload); + $this->assertHttpCreated(); - 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); + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('public@example.com', $response[0]['subscriber']['email'] ?? null); - $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']); - } + $subscriber = $this->entityManager?->getRepository(Subscriber::class) + ->findOneBy(['email' => 'public@example.com']); + self::assertInstanceOf(Subscriber::class, $subscriber); - 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); + $definition = $this->entityManager?->getRepository(SubscriberAttributeDefinition::class) + ->findOneBy(['name' => 'Country']); + self::assertInstanceOf(SubscriberAttributeDefinition::class, $definition); - $this->authenticatedJsonRequest('PUT', '/api/v2/subscribe-pages/9999/data', content: $payload); - $this->assertHttpNotFound(); + $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/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())); } } diff --git a/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php new file mode 100644 index 0000000..5c91d54 --- /dev/null +++ b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php @@ -0,0 +1,60 @@ +normalizer = new SubscribePagePublicNormalizer( + $this->createMock(SubscriberAttributeDefinitionRepository::class), + $this->createMock(SubscriberListRepository::class) + ); + } + + public function testSupportsNormalization(): void + { + $page = $this->createMock(SubscribePage::class); + + $this->assertTrue($this->normalizer->supportsNormalization($page)); + $this->assertFalse($this->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); + + $expected = [ + 'id' => 42, + 'title' => 'welcome@example.org', + 'data' => [], + ]; + + $this->assertSame($expected, $this->normalizer->normalize($page)); + } + + public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void + { + $this->assertSame([], $this->normalizer->normalize(new stdClass())); + } +} diff --git a/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php b/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php new file mode 100644 index 0000000..04aa38f --- /dev/null +++ b/tests/Unit/Subscription/Service/PublicSubscriptionAttributeRuleProviderTest.php @@ -0,0 +1,64 @@ +repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); + $this->subscribePageManager = $this->createMock(SubscribePageManager::class); + $this->provider = new PublicSubscriptionAttributeRuleProvider( + attributeDefinitionRepository: $this->repository, + subscribePageManager: $this->subscribePageManager + ); + } + + 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([ + '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 new file mode 100644 index 0000000..57a9b61 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php @@ -0,0 +1,131 @@ +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()); + } +}