diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index b961c1e9..33eee07d 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -3,8 +3,11 @@ name: Update phplist-api-client OpenAPI on: push: branches: - - '**' + - dev + - main pull_request: + branches: + - main jobs: generate-openapi: diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml new file mode 100644 index 00000000..0aac1038 --- /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 92598e82..443f5c96 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", @@ -126,6 +126,11 @@ "type": "attribute", "prefix": "/api/v2" }, + "rest-api-configuration": { + "resource": "@PhpListRestBundle/Configuration/Controller/", + "type": "attribute", + "prefix": "/api/v2" + }, "rest-api-analitics": { "resource": "@PhpListRestBundle/Statistics/Controller/", "type": "attribute", diff --git a/config/services/controllers.yml b/config/services/controllers.yml index 9f7566ea..57532cf6 100644 --- a/config/services/controllers.yml +++ b/config/services/controllers.yml @@ -25,6 +25,13 @@ services: autoconfigure: true public: true + PhpList\RestBundle\Configuration\Controller\: + resource: '../src/Configuration/Controller' + tags: [ 'controller.service_arguments' ] + autowire: true + autoconfigure: true + public: true + PhpList\RestBundle\Statistics\Controller\: resource: '../src/Statistics/Controller' tags: [ 'controller.service_arguments' ] diff --git a/config/services/services.yml b/config/services/services.yml index 17127575..b76fc913 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -3,6 +3,10 @@ services: autowire: true autoconfigure: true + PhpList\RestBundle\Subscription\Service\PublicSubscriptionAttributeRuleProvider: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\ForwardingGuard: autowire: true autoconfigure: true diff --git a/config/services/validators.yml b/config/services/validators.yml index 8d68037f..ba0b8826 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -37,11 +37,26 @@ services: autoconfigure: true tags: [ 'validator.constraint_validator' ] + PhpList\RestBundle\Configuration\Validator\Constraint\UniqueConfigKeyValidator: + autowire: true + autoconfigure: true + tags: [ 'validator.constraint_validator' ] + PhpList\RestBundle\Subscription\Validator\Constraint\ListExistsValidator: autowire: true autoconfigure: true 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 ddd648ad..91694d2f 100644 --- a/src/Common/Validator/RequestValidator.php +++ b/src/Common/Validator/RequestValidator.php @@ -20,7 +20,7 @@ public function __construct( ) { } - public function validate(Request $request, string $dtoClass): RequestInterface + public function validate(Request $request, string $dtoClass, ?callable $beforeValidation = null): RequestInterface { try { $content = $request->getContent(); @@ -53,6 +53,10 @@ public function validate(Request $request, string $dtoClass): RequestInterface ); } + if ($beforeValidation !== null) { + $beforeValidation($dto); + } + return $this->validateDto($dto); } diff --git a/src/Configuration/Controller/ConfigController.php b/src/Configuration/Controller/ConfigController.php new file mode 100644 index 00000000..52b5afe1 --- /dev/null +++ b/src/Configuration/Controller/ConfigController.php @@ -0,0 +1,346 @@ +denyUnlessSettingsAdmin($request, 'You are not allowed to view configuration.'); + $items = $this->manager->getAllEditable(); + + usort( + $items, + fn (Config $aConf, Config $bConf): int => strcmp( + strtolower($aConf->getKey()), + strtolower($bConf->getKey()) + ) + ); + + $count = count($items); + + return $this->json( + data: [ + 'items' => array_map(fn($config) => $this->normalizer->normalize($config), $items), + 'pagination' => [ + 'total' => $count, + 'limit' => $count + 1, + 'has_more' => false, + 'next_cursor' => null + ] + ], + status: Response::HTTP_OK + ); + } + + #[Route('/{key}', name: 'get_one', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/configs/{key}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Returns one configuration item by key.', + summary: 'Gets a configuration item.', + tags: ['configs'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'key', + description: 'Configuration key', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Config') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function getOne( + Request $request, + #[MapEntity(mapping: ['key' => 'key'])] ?Config $config, + ): JsonResponse { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to view configuration.'); + if ($config === null) { + throw $this->createNotFoundException('Configuration item not found.'); + } + + return $this->json($this->normalizer->normalize($config), Response::HTTP_OK); + } + + #[Route('', name: 'create', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/configs', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Creates a configuration item.', + summary: 'Creates a configuration item.', + requestBody: new OA\RequestBody( + description: 'Configuration item data', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ConfigRequest') + ), + tags: ['configs'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 201, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Config') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 409, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/AlreadyExistsResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function create(Request $request): JsonResponse + { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to create configuration.'); + /* @var CreateConfigRequest $configRequest */ + $configRequest = $this->validator->validate($request, CreateConfigRequest::class); + + $config = $this->manager->create($configRequest->getDto()); + $this->entityManager->flush(); + + return $this->json($this->normalizer->normalize($config), Response::HTTP_CREATED); + } + + #[Route('/{key}', name: 'update', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['PUT'])] + #[OA\Put( + path: '/api/v2/configs/{key}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Updates a configuration item value.', + summary: 'Updates a configuration item.', + requestBody: new OA\RequestBody( + description: 'Configuration item data', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ConfigUpdateRequest') + ), + tags: ['configs'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'key', + description: 'Configuration key', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent(ref: '#/components/schemas/Config') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function update( + Request $request, + #[MapEntity(mapping: ['key' => 'key'])] ?Config $config = null + ): JsonResponse { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to update configuration.'); + if ($config === null) { + throw $this->createNotFoundException('Configuration item not found.'); + } + /* @var UpdateConfigRequest $dto */ + $dto = $this->validator->validate($request, UpdateConfigRequest::class); + + try { + $this->manager->update($config, $dto->getDto()['value']); + } catch (ConfigNotEditableException $exception) { + throw $this->createAccessDeniedException($exception->getMessage()); + } + $this->entityManager->flush(); + + return $this->json($this->normalizer->normalize($config), Response::HTTP_OK); + } + + #[Route('/{key}', name: 'delete', requirements: ['key' => '[A-Za-z0-9_.:-]+'], methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/configs/{key}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Deletes a configuration item.', + summary: 'Deletes a configuration item.', + tags: ['configs'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'key', + description: 'Configuration key', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: Response::HTTP_NO_CONTENT, description: 'Success'), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + ] + )] + public function delete( + Request $request, + #[MapEntity(mapping: ['key' => 'key'])] ?Config $config = null + ): JsonResponse { + $this->denyUnlessSettingsAdmin($request, 'You are not allowed to delete configuration.'); + if ($config === null) { + throw $this->createNotFoundException('Configuration item not found.'); + } + + $this->manager->delete($config); + $this->entityManager->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + private function denyUnlessSettingsAdmin(Request $request, string $message): void + { + $admin = $this->requireAuthentication($request); + if (!$admin->getPrivileges()->has(PrivilegeFlag::Settings)) { + throw $this->createAccessDeniedException($message); + } + } +} diff --git a/src/Configuration/Request/CreateConfigRequest.php b/src/Configuration/Request/CreateConfigRequest.php new file mode 100644 index 00000000..766a926a --- /dev/null +++ b/src/Configuration/Request/CreateConfigRequest.php @@ -0,0 +1,53 @@ +key, + value: $this->value, + editable: $this->editable, + type: $this->type + ); + } +} diff --git a/src/Configuration/Request/UpdateConfigRequest.php b/src/Configuration/Request/UpdateConfigRequest.php new file mode 100644 index 00000000..671be899 --- /dev/null +++ b/src/Configuration/Request/UpdateConfigRequest.php @@ -0,0 +1,31 @@ + $this->value, + ]; + } +} diff --git a/src/Configuration/Serializer/ConfigNormalizer.php b/src/Configuration/Serializer/ConfigNormalizer.php new file mode 100644 index 00000000..7ffa6975 --- /dev/null +++ b/src/Configuration/Serializer/ConfigNormalizer.php @@ -0,0 +1,51 @@ + $object->getKey(), + 'value' => $object->getValue(), + 'editable' => $object->isEditable(), + 'type' => $object->getType(), + ]; + } + + /** + * Checks whether the value can be normalized. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Config; + } +} diff --git a/src/Configuration/Validator/Constraint/UniqueConfigKey.php b/src/Configuration/Validator/Constraint/UniqueConfigKey.php new file mode 100644 index 00000000..a2ec7c33 --- /dev/null +++ b/src/Configuration/Validator/Constraint/UniqueConfigKey.php @@ -0,0 +1,13 @@ +entityManager->find(Config::class, $value) instanceof Config) { + throw new ConflictHttpException($constraint->message); + } + } +} diff --git a/src/Messaging/Controller/BounceController.php b/src/Messaging/Controller/BounceController.php index 3ab07942..30587aa1 100644 --- a/src/Messaging/Controller/BounceController.php +++ b/src/Messaging/Controller/BounceController.php @@ -81,8 +81,15 @@ public function __construct( response: 200, description: 'Success', content: new OA\JsonContent( - type: 'array', - items: new OA\Items(ref: '#/components/schemas/BounceView') + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/BounceView') + ), + new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') + ], + type: 'object' ) ), new OA\Response( diff --git a/src/PhpListRestBundle.php b/src/PhpListRestBundle.php index a856c869..dd20cbf4 100644 --- a/src/PhpListRestBundle.php +++ b/src/PhpListRestBundle.php @@ -18,7 +18,7 @@ description: 'This is the OpenAPI documentation for phpList API.', title: 'phpList API Documentation', contact: new OA\Contact( - email: 'support@phplist.com' + email: 'tatevik@phplist.com' ), license: new OA\License( name: 'AGPL-3.0-or-later', diff --git a/src/Subscription/Controller/SubscribePageController.php b/src/Subscription/Controller/SubscribePageController.php index ef7a59c3..edfcac56 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,16 +31,17 @@ 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', - tags: ['subscriptions'], + summary: 'Get subscribe pages list', + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -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,10 +122,22 @@ 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 + ), ] ) ), - tags: ['subscriptions'], + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -142,27 +175,26 @@ public function createPage(Request $request): JsonResponse /** @var SubscribePageRequest $createRequest */ $createRequest = $this->validator->validate($request, SubscribePageRequest::class); - $page = $this->subscribePageManager->createPage($createRequest->title, $createRequest->active, $admin); + $page = $this->subscribePageManager->createPage( + title: $createRequest->title, + active: $createRequest->active, + owner: $admin + ); + if ($createRequest->hasData()) { + $this->entityManager->flush(); + $this->subscribePageManager->syncPageData(data: $createRequest->getDataMap(), page: $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), - ] - ) - ), - tags: ['subscriptions'], + summary: 'Get subscribe page', + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -183,7 +215,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,94 +229,48 @@ 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'], + ), + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -305,17 +291,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,50 +302,45 @@ 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), - ] - ) - ), - tags: ['subscriptions'], + summary: 'Delete subscribe page', + tags: ['subscribe-pages'], parameters: [ new OA\Parameter( name: 'php-auth-pw', @@ -387,18 +358,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 +371,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 00000000..a296c2bd --- /dev/null +++ b/src/Subscription/Controller/SubscribePagePublicController.php @@ -0,0 +1,242 @@ + '\\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: ['subscribe-pages'], + 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: ['subscribe-pages'], + 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: 204, + description: 'Success' + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function subscribe(Request $request, int $pageId): JsonResponse + { + $page = $this->subscribePageManager->findPublicPage(id: $pageId); + if (!$page) { + throw $this->createNotFoundException('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->subscriberListRepository->find($subscriptionRequest->listId); + if ($list === null) { + throw $this->createNotFoundException('Subscriber list does not exists.'); + } + $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(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + #[Route('/{pageId}', name: 'unsubscribe', methods: ['DELETE'])] + #[OA\Delete( + path: '/api/v2/public/subscribe-pages/{pageId}', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production.' . + 'Unsubscribe subscriber from lists of subscribe page.', + summary: 'Delete subscription', + requestBody: new OA\RequestBody( + description: '', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/PublicUnsubscriptionRequest') + ), + tags: ['subscribe-pages'], + parameters: [ + new OA\Parameter( + name: 'pageId', + description: 'Subscribe page ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ + new OA\Response( + response: 204, + description: 'Success' + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function unsubscribe(Request $request, int $pageId): JsonResponse + { + $page = $this->subscribePageManager->findPage(id: $pageId); + if (!$page) { + throw $this->createNotFoundException('Subscribe page not found.'); + } + + /** @var SubscribePageData|null $listsField */ + $listsField = array_find( + $page->getData(), + fn (SubscribePageData $data) => $data->getName() === 'lists' + ); + + if ($listsField === null) { + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + $listsIds = explode(',', $listsField->getData() ?? ''); + if ($listsIds == []) { + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + /** @var PublicUnsubscriptionRequest $unsubscribeRequest */ + $unsubscribeRequest = $this->validator->validate( + request: $request, + dtoClass: PublicUnsubscriptionRequest::class + ); + + $lists = $this->subscriberListRepository->findBy(['id' => $listsIds]); + foreach ($lists as $list) { + $this->subscriptionManager->deleteSubscriptions( + subscriberList: $list, + emails: [$unsubscribeRequest->email] + ); + } + $this->entityManager->flush(); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 2a038f96..b67db97d 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -28,21 +28,14 @@ #[Route('/lists', name: 'subscription_')] class SubscriptionController extends BaseController { - private SubscriptionManager $subscriptionManager; - private SubscriptionNormalizer $subscriptionNormalizer; - private EntityManagerInterface $entityManager; - public function __construct( Authentication $authentication, RequestValidator $validator, - SubscriptionManager $subscriptionManager, - SubscriptionNormalizer $subscriptionNormalizer, - EntityManagerInterface $entityManager, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriptionNormalizer $subscriptionNormalizer, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); - $this->subscriptionManager = $subscriptionManager; - $this->subscriptionNormalizer = $subscriptionNormalizer; - $this->entityManager = $entityManager; } #[Route('/{listId}/subscribers', name: 'create', requirements: ['listId' => '\d+'], methods: ['POST'])] diff --git a/src/Subscription/Request/PublicSubscriptionRequest.php b/src/Subscription/Request/PublicSubscriptionRequest.php new file mode 100644 index 00000000..6fef6ca6 --- /dev/null +++ b/src/Subscription/Request/PublicSubscriptionRequest.php @@ -0,0 +1,103 @@ + 'John', + 'lastname' => 'Grigoryan', + 'country' => 'Armenia', + ], + additionalProperties: true + ), + ] +)] +#[ValidPublicSubscription] +class PublicSubscriptionRequest implements RequestInterface +{ + #[Assert\NotBlank] + #[Assert\Email] + public ?string $email = null; + + #[ListExistsPublic] + #[Assert\NotNull] + #[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/PublicUnsubscriptionRequest.php b/src/Subscription/Request/PublicUnsubscriptionRequest.php new file mode 100644 index 00000000..b81c2672 --- /dev/null +++ b/src/Subscription/Request/PublicUnsubscriptionRequest.php @@ -0,0 +1,38 @@ +email !== null) { + $this->email = trim($this->email); + } + + return $this; + } +} diff --git a/src/Subscription/Request/SubscribePageRequest.php b/src/Subscription/Request/SubscribePageRequest.php index 16f3eee5..31447f15 100644 --- a/src/Subscription/Request/SubscribePageRequest.php +++ b/src/Subscription/Request/SubscribePageRequest.php @@ -16,6 +16,61 @@ class SubscribePageRequest implements RequestInterface #[Assert\Type(type: 'bool')] public bool $active = false; + /** + * @var array|null + */ + #[Assert\Type(type: 'array')] + #[Assert\All(constraints: [ + new Assert\Collection( + fields: [ + 'key' => new Assert\Required([ + new Assert\NotBlank(), + new Assert\Type(type: 'string'), + ]), + 'value' => new Assert\Required([ + new Assert\Type(type: 'string'), + ]), + ], + allowExtraFields: false, + allowMissingFields: false + ), + ])] + private ?array $data = null; + + private bool $dataProvided = false; + + public function setData(?array $data): void + { + $this->data = $data; + $this->dataProvided = true; + } + + public function hasData(): bool + { + return $this->dataProvided; + } + + /** @return array|null */ + public function getData(): ?array + { + return $this->data; + } + + /** @return array */ + public function getDataMap(): array + { + if ($this->data === null) { + return []; + } + + $result = []; + foreach ($this->data as $item) { + $result[$item['key']] = $item['value']; + } + + return $result; + } + public function getDto(): SubscribePageRequest { return $this; diff --git a/src/Subscription/Serializer/SubscribePageDataNormalizer.php b/src/Subscription/Serializer/SubscribePageDataNormalizer.php new file mode 100644 index 00000000..eeef9f8f --- /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 d58a663b..702b6484 100644 --- a/src/Subscription/Serializer/SubscribePageNormalizer.php +++ b/src/Subscription/Serializer/SubscribePageNormalizer.php @@ -16,12 +16,18 @@ new OA\Property(property: 'title', type: 'string', example: 'Subscribe to our newsletter'), new OA\Property(property: 'active', type: 'boolean', example: true), new OA\Property(property: 'owner', ref: '#/components/schemas/Administrator'), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/SubscribePageData') + ), ], )] class SubscribePageNormalizer implements NormalizerInterface { public function __construct( private readonly AdministratorNormalizer $adminNormalizer, + private readonly SubscribePageDataNormalizer $dataNormalizer, ) { } @@ -39,6 +45,9 @@ public function normalize($object, string $format = null, array $context = []): 'title' => $object->getTitle(), 'active' => $object->isActive(), 'owner' => $this->adminNormalizer->normalize($object->getOwner()), + 'data' => array_map(function ($data) { + return $this->dataNormalizer->normalize($data); + }, $object->getData()) ]; } diff --git a/src/Subscription/Serializer/SubscribePagePublicNormalizer.php b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php new file mode 100644 index 00000000..25ab7c24 --- /dev/null +++ b/src/Subscription/Serializer/SubscribePagePublicNormalizer.php @@ -0,0 +1,161 @@ + $object->getId(), + 'title' => $object->getTitle(), + 'data' => array_reduce( + $object->getData(), + function (array $carry, SubscribePageData $data) { + $value = $data->getData(); + if ($data->getName() === 'attributes') { + $attributeIds = str_replace('+', ',', $data->getData()); + $ids = array_filter(explode(',', $attributeIds)); + $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 00000000..ff39195c --- /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 00000000..9b8a00c2 --- /dev/null +++ b/src/Subscription/Validator/Constraint/ListExistsPublic.php @@ -0,0 +1,22 @@ +mode = $mode ?? $this->mode; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Subscription/Validator/Constraint/ListExistsPublicValidator.php b/src/Subscription/Validator/Constraint/ListExistsPublicValidator.php new file mode 100644 index 00000000..bfa4f953 --- /dev/null +++ b/src/Subscription/Validator/Constraint/ListExistsPublicValidator.php @@ -0,0 +1,35 @@ +subscriberListRepository->findBy(['id' => (int)$value, 'public' => true]); + + if (!$existingList) { + throw new NotFoundHttpException('Subscriber list does not exists.'); + } + } +} diff --git a/src/Subscription/Validator/Constraint/ListExistsValidator.php b/src/Subscription/Validator/Constraint/ListExistsValidator.php index 8cbcd216..4eff2f4a 100644 --- a/src/Subscription/Validator/Constraint/ListExistsValidator.php +++ b/src/Subscription/Validator/Constraint/ListExistsValidator.php @@ -9,7 +9,6 @@ use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; -use Symfony\Component\Validator\Exception\UnexpectedValueException; class ListExistsValidator extends ConstraintValidator { diff --git a/src/Subscription/Validator/Constraint/ValidPublicSubscription.php b/src/Subscription/Validator/Constraint/ValidPublicSubscription.php new file mode 100644 index 00000000..18fec6b2 --- /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/Composer/ScriptsTest.php b/tests/Integration/Composer/ScriptsTest.php index 436b4d16..5b0df800 100644 --- a/tests/Integration/Composer/ScriptsTest.php +++ b/tests/Integration/Composer/ScriptsTest.php @@ -123,6 +123,7 @@ public static function moduleRoutingDataProvider(): array 'identity' => ["resource: '@PhpListRestBundle/Identity/Controller/'"], 'messaging' => ["resource: '@PhpListRestBundle/Messaging/Controller/'"], 'subscription' => ["resource: '@PhpListRestBundle/Subscription/Controller/'"], + 'configuration' => ["resource: '@PhpListRestBundle/Configuration/Controller/'"], 'type' => ['type: attribute'], ]; } diff --git a/tests/Integration/Configuration/Controller/ConfigControllerTest.php b/tests/Integration/Configuration/Controller/ConfigControllerTest.php new file mode 100644 index 00000000..3602987c --- /dev/null +++ b/tests/Integration/Configuration/Controller/ConfigControllerTest.php @@ -0,0 +1,229 @@ +get(ConfigController::class)); + } + + public function testListWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/configs'); + $this->assertHttpForbidden(); + } + + public function testListWithExpiredSessionKeyReturnsForbidden(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + self::getClient()->request( + 'GET', + '/api/v2/configs', + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'expiredtoken'] + ); + + $this->assertHttpForbidden(); + } + + public function testListWithValidSessionKeyReturnsOkayWithPaginationStructure(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/configs'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertIsArray($response); + self::assertArrayHasKey('items', $response); + self::assertArrayHasKey('pagination', $response); + } + + public function testListReturnsCreatedConfigData(): void + { + $config = new Config(); + $config->setKey('organisation_name'); + $config->setValue('Example Organisation'); + $config->setEditable(true); + $config->setType('text'); + + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('GET', '/api/v2/configs'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertNotEmpty($response['items']); + + $found = false; + foreach ($response['items'] as $item) { + if ($item['key'] === 'organisation_name') { + $found = true; + self::assertSame('Example Organisation', $item['value']); + self::assertTrue($item['editable']); + self::assertSame('text', $item['type']); + } + } + + self::assertTrue($found, 'Created config item not found in list response.'); + } + + public function testGetOneWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('GET', '/api/v2/configs/organisation_name'); + $this->assertHttpForbidden(); + } + + public function testGetOneWithValidSessionReturnsOkayAndData(): void + { + $config = new Config(); + $config->setKey('site_title'); + $config->setValue('My Site'); + $config->setEditable(true); + + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('GET', '/api/v2/configs/site_title'); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('site_title', $response['key']); + self::assertSame('My Site', $response['value']); + self::assertTrue($response['editable']); + } + + public function testGetOneWithInvalidKeyReturnsNotFound(): void + { + $this->authenticatedJsonRequest('GET', '/api/v2/configs/nonexistent_key'); + $this->assertHttpNotFound(); + } + + public function testCreateWithoutSessionKeyReturnsForbidden(): void + { + $json = json_encode(['key' => 'new_key', 'value' => 'val']); + $this->jsonRequest('POST', '/api/v2/configs', [], [], [], $json); + $this->assertHttpForbidden(); + } + + public function testCreateWithValidSessionCreatesConfig(): void + { + $json = json_encode(['key' => 'new_key', 'value' => 'val', 'editable' => true, 'type' => 'text']); + $this->authenticatedJsonRequest('POST', '/api/v2/configs', [], [], [], $json); + + $this->assertHttpCreated(); + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('new_key', $response['key']); + self::assertSame('val', $response['value']); + self::assertTrue($response['editable']); + self::assertSame('text', $response['type']); + } + + public function testCreateWithDuplicateKeyReturnsConflict(): void + { + $config = new Config(); + $config->setKey('dup_key'); + $config->setValue('first'); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $json = json_encode(['key' => 'dup_key', 'value' => 'second']); + $this->authenticatedJsonRequest('POST', '/api/v2/configs', [], [], [], $json); + + $this->assertHttpConflict(); + } + + public function testUpdateWithoutSessionKeyReturnsForbidden(): void + { + $this->jsonRequest('PUT', '/api/v2/configs/some_key', [], [], [], json_encode(['value' => 'x'])); + $this->assertHttpForbidden(); + } + + public function testUpdateWithValidSessionUpdatesValue(): void + { + $config = new Config(); + $config->setKey('up_key'); + $config->setValue('old'); + $config->setEditable(true); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('PUT', '/api/v2/configs/up_key', [], [], [], json_encode(['value' => 'new'])); + + $this->assertHttpOkay(); + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('new', $response['value']); + } + + public function testUpdateForNonexistentKeyReturnsNotFound(): void + { + $this->authenticatedJsonRequest( + 'PUT', + '/api/v2/configs/does_not_exist', + [], + [], + [], + json_encode(['value' => 'x']) + ); + $this->assertHttpNotFound(); + } + + public function testUpdateNonEditableReturnsForbidden(): void + { + $config = new Config(); + $config->setKey('locked_key'); + $config->setValue('orig'); + $config->setEditable(false); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest( + 'PUT', + '/api/v2/configs/locked_key', + [], + [], + [], + json_encode(['value' => 'changed']) + ); + + $this->assertHttpForbidden(); + } + + public function testDeleteWithoutSessionKeyReturnsForbidden(): void + { + self::getClient()->request('DELETE', '/api/v2/configs/some_key'); + $this->assertHttpForbidden(); + } + + public function testDeleteWithValidSessionDeletesConfig(): void + { + $config = new Config(); + $config->setKey('del_key'); + $config->setValue('to delete'); + $this->entityManager->persist($config); + $this->entityManager->flush(); + + $this->authenticatedJsonRequest('DELETE', '/api/v2/configs/del_key'); + $this->assertHttpNoContent(); + + $this->entityManager->clear(); + self::assertNull($this->entityManager->getRepository(Config::class)->find('del_key')); + } + + public function testDeleteNonexistentReturnsNotFound(): void + { + $this->authenticatedJsonRequest('DELETE', '/api/v2/configs/unknown_key'); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php index fa2d541a..6635bdd4 100644 --- a/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php +++ b/tests/Integration/Subscription/Controller/SubscribePageControllerTest.php @@ -70,9 +70,12 @@ 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); + $this->jsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload); $this->assertHttpForbidden(); } @@ -83,9 +86,12 @@ 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); + $this->authenticatedJsonRequest('POST', '/api/v2/subscribe-pages/', content: $payload); $this->assertHttpCreated(); $data = $this->getDecodedJsonResponseContent(); @@ -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/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php b/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php new file mode 100644 index 00000000..010c2b7b --- /dev/null +++ b/tests/Integration/Subscription/Controller/SubscribePagePublicControllerTest.php @@ -0,0 +1,110 @@ +get(SubscribePagePublicController::class) + ); + } + + public function testGetPublicPageReturnsActivePage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + + $this->jsonRequest('GET', '/api/v2/public/subscribe-pages/1'); + + $this->assertHttpOkay(); + $data = $this->getDecodedJsonResponseContent(); + + self::assertSame(1, $data['id']); + self::assertSame('Welcome Page', $data['title']); + self::assertSame([], $data['data']); + } + + public function testGetPublicPageReturnsNotFoundForInactivePage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + + $this->jsonRequest('GET', '/api/v2/public/subscribe-pages/2'); + + $this->assertHttpNotFound(); + } + + public function testGetPublicPageReturnsNotFoundForUnknownPage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscribePageFixture::class]); + + $this->jsonRequest('GET', '/api/v2/public/subscribe-pages/9999'); + + $this->assertHttpNotFound(); + } + + public function testSubscribeReturnsNotFoundForUnknownPage(): void + { + $this->loadFixtures([AdministratorFixture::class, SubscriberListFixture::class]); + $payload = json_encode([ + 'email' => 'public@example.com', + 'confirm_email' => 'public@example.com', + 'list_id' => 1, + ], JSON_THROW_ON_ERROR); + + $this->jsonRequest('POST', '/api/v2/public/subscribe-pages/9999', [], [], [], $payload); + + $this->assertHttpNotFound(); + } + + public function testSubscribeCreatesSubscriptionAndAttributes(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + SubscribePageFixture::class, + SubscriberListFixture::class, + SubscriberAttributeDefinitionFixture::class, + ]); + + $payload = json_encode([ + 'email' => 'public@example.com', + 'confirm_email' => 'public@example.com', + 'list_id' => 1, + 'attributes' => [ + 'Country' => 'on', + ], + ], JSON_THROW_ON_ERROR); + + $this->jsonRequest('POST', '/api/v2/public/subscribe-pages/1', [], [], [], $payload); + $this->assertHttpNoContent(); + + $subscriber = $this->entityManager?->getRepository(Subscriber::class) + ->findOneBy(['email' => 'public@example.com']); + self::assertInstanceOf(Subscriber::class, $subscriber); + + $definition = $this->entityManager?->getRepository(SubscriberAttributeDefinition::class) + ->findOneBy(['name' => 'Country']); + self::assertInstanceOf(SubscriberAttributeDefinition::class, $definition); + + $value = $this->entityManager?->getRepository(SubscriberAttributeValue::class) + ->findOneBy([ + 'subscriber' => $subscriber, + 'attributeDefinition' => $definition, + ]); + self::assertInstanceOf(SubscriberAttributeValue::class, $value); + self::assertSame('on', $value->getValue()); + } +} diff --git a/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php index 3fbb79a2..c85ee74b 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberAttributeDefinitionFixture.php @@ -20,7 +20,6 @@ public function load(ObjectManager $manager): void $definition->setListOrder(1); $definition->setDefaultValue('US'); $definition->setRequired(true); - $definition->setTableName('list_attributes'); $manager->persist($definition); $manager->flush(); diff --git a/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php b/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php index 2219604a..0e7ef3be 100644 --- a/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php +++ b/tests/Integration/Subscription/Fixtures/SubscriberAttributeValueFixture.php @@ -22,7 +22,6 @@ public function load(ObjectManager $manager): void $definition->setListOrder(1); $definition->setDefaultValue('US'); $definition->setRequired(true); - $definition->setTableName('list_attributes'); $manager->persist($definition); diff --git a/tests/Unit/Configuration/Request/CreateConfigRequestTest.php b/tests/Unit/Configuration/Request/CreateConfigRequestTest.php new file mode 100644 index 00000000..a806ba06 --- /dev/null +++ b/tests/Unit/Configuration/Request/CreateConfigRequestTest.php @@ -0,0 +1,200 @@ +key = 'organisation_name'; + $request->value = 'Example Organisation'; + + self::assertInstanceOf(RequestInterface::class, $request); + } + + public function testEditableDefaultsToTrue(): void + { + $request = new CreateConfigRequest(); + + self::assertTrue($request->editable); + } + + public function testTypeDefaultsToNull(): void + { + $request = new CreateConfigRequest(); + + self::assertNull($request->type); + } + + public function testGetDtoReturnsCreateConfigDtoWithExpectedValues(): void + { + $request = new CreateConfigRequest(); + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = false; + $request->type = 'text'; + + $dto = $request->getDto(); + + self::assertInstanceOf(CreateConfigDto::class, $dto); + self::assertSame('organisation_name', $dto->key); + self::assertSame('Example Organisation', $dto->value); + self::assertFalse($dto->editable); + self::assertSame('text', $dto->type); + } + + public function testGetDtoUsesDefaultsWhenNotExplicitlySet(): void + { + $request = new CreateConfigRequest(); + $request->key = 'site_title'; + $request->value = 'My Site'; + + $dto = $request->getDto(); + + self::assertTrue($dto->editable); + self::assertNull($dto->type); + } + + public function testGetDtoReturnsCreateConfigDto(): void + { + $request = new CreateConfigRequest(); + + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = true; + $request->type = 'text'; + + $dto = $request->getDto(); + + $this->assertInstanceOf(CreateConfigDto::class, $dto); + } + + public function testGetDtoMapsAllProperties(): void + { + $request = new CreateConfigRequest(); + + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = false; + $request->type = 'text'; + + $dto = $request->getDto(); + + $this->assertSame('organisation_name', $dto->key); + $this->assertSame('Example Organisation', $dto->value); + $this->assertFalse($dto->editable); + $this->assertSame('text', $dto->type); + } + + public function testGetDtoWithNullType(): void + { + $request = new CreateConfigRequest(); + + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->editable = true; + $request->type = null; + + $dto = $request->getDto(); + + $this->assertNull($dto->type); + } + + public function testKeyFailsWhenBlank(): void + { + $violations = $this->createValidator()->validate('', [new NotBlank()]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testKeyFailsWhenExceedsMaxLength(): void + { + $longKey = str_repeat('a', 36); + + $request = new CreateConfigRequest(); + $request->key = $longKey; + $request->value = 'value'; + + $validator = $this->createValidator(); + $violations = $validator->validate($request->key, [ + new Length(max: 35), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testKeyFailsWithInvalidCharacters(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate('invalid key!', [ + new Regex(pattern: '/^[A-Za-z0-9_.:-]+$/'), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testKeyPassesWithAllowedCharacters(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate('organisation_name.v1:test-2', [ + new Regex(pattern: '/^[A-Za-z0-9_.:-]+$/'), + ]); + + self::assertCount(0, $violations); + } + + public function testValueFailsWhenNotString(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate(123, [ + new Type('string'), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testTypeFailsWhenExceedsMaxLength(): void + { + $validator = $this->createValidator(); + + $violations = $validator->validate(str_repeat('a', 26), [ + new Length(max: 25), + ]); + + self::assertGreaterThan(0, count($violations)); + } + + public function testTypeAllowsNull(): void + { + $request = new CreateConfigRequest(); + $request->key = 'organisation_name'; + $request->value = 'Example Organisation'; + $request->type = null; + + self::assertNull($request->type); + } + + private function createValidator(): ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + } +} diff --git a/tests/Unit/Configuration/Request/UpdateConfigRequestTest.php b/tests/Unit/Configuration/Request/UpdateConfigRequestTest.php new file mode 100644 index 00000000..891aff4c --- /dev/null +++ b/tests/Unit/Configuration/Request/UpdateConfigRequestTest.php @@ -0,0 +1,69 @@ +value = 'Example Organisation'; + + self::assertSame(['value' => 'Example Organisation'], $request->getDto()); + } + + public function testValidationPassesForValidStringValue(): void + { + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + + $request = new UpdateConfigRequest(); + $request->value = 'Example Organisation'; + + $violations = $validator->validate($request); + + self::assertCount(0, $violations); + } + + public function testValidationFailsForEmptyStringIsActuallyValid(): void + { + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + + $request = new UpdateConfigRequest(); + $request->value = ''; + + $violations = $validator->validate($request); + + self::assertCount(0, $violations); + } + + public function testValidationFailsForNullString(): void + { + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + + $request = new UpdateConfigRequest(); + + $violations = $validator->validate($request); + + self::assertCount(1, $violations); + } + + public function testImplementsRequestInterface(): void + { + $request = new UpdateConfigRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + } +} diff --git a/tests/Unit/Configuration/Serializer/ConfigNormalizerTest.php b/tests/Unit/Configuration/Serializer/ConfigNormalizerTest.php new file mode 100644 index 00000000..7b2dd23b --- /dev/null +++ b/tests/Unit/Configuration/Serializer/ConfigNormalizerTest.php @@ -0,0 +1,80 @@ +normalizer = new ConfigNormalizer(); + } + + public function testSupportsNormalizationReturnsTrueForConfig(): void + { + $config = $this->createMock(Config::class); + + self::assertTrue($this->normalizer->supportsNormalization($config)); + } + + public function testSupportsNormalizationReturnsFalseForNonConfig(): void + { + self::assertFalse($this->normalizer->supportsNormalization(new stdClass())); + } + + public function testSupportsNormalizationReturnsFalseForNull(): void + { + self::assertFalse($this->normalizer->supportsNormalization(null)); + } + + public function testNormalizeReturnsExpectedArrayForConfig(): void + { + $config = $this->createMock(Config::class); + $config->method('getKey')->willReturn('organisation_name'); + $config->method('getValue')->willReturn('Example Organisation'); + $config->method('isEditable')->willReturn(true); + $config->method('getType')->willReturn('text'); + + $result = $this->normalizer->normalize($config); + + self::assertSame([ + 'key' => 'organisation_name', + 'value' => 'Example Organisation', + 'editable' => true, + 'type' => 'text', + ], $result); + } + + public function testNormalizeHandlesNullValueAndType(): void + { + $config = $this->createMock(Config::class); + $config->method('getKey')->willReturn('some_key'); + $config->method('getValue')->willReturn(null); + $config->method('isEditable')->willReturn(false); + $config->method('getType')->willReturn(null); + + $result = $this->normalizer->normalize($config); + + self::assertSame([ + 'key' => 'some_key', + 'value' => null, + 'editable' => false, + 'type' => null, + ], $result); + } + + public function testNormalizeReturnsEmptyArrayForNonConfigObject(): void + { + $result = $this->normalizer->normalize(new stdClass()); + + self::assertSame([], $result); + } +} diff --git a/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php b/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php new file mode 100644 index 00000000..ae9cb6a3 --- /dev/null +++ b/tests/Unit/Configuration/Validator/Constraint/UniqueConfigKeyValidatorTest.php @@ -0,0 +1,87 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->validator = new UniqueConfigKeyValidator($this->entityManager); + } + + public function testValidateSkipsNull(): void + { + $this->entityManager->expects(self::never())->method('find'); + + $this->validator->validate(null, new UniqueConfigKey()); + + self::assertTrue(true); + } + + public function testValidateSkipsEmptyString(): void + { + $this->entityManager->expects(self::never())->method('find'); + + $this->validator->validate('', new UniqueConfigKey()); + + self::assertTrue(true); + } + + public function testValidateThrowsUnexpectedTypeException(): void + { + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate('organisation_name', $this->createMock(Constraint::class)); + } + + public function testValidateThrowsUnexpectedValueException(): void + { + $this->expectException(UnexpectedValueException::class); + + $this->validator->validate(123, new UniqueConfigKey()); + } + + public function testValidateThrowsConflictHttpExceptionIfConfigKeyExists(): void + { + $this->entityManager + ->expects(self::once()) + ->method('find') + ->with(Config::class, 'organisation_name') + ->willReturn(new Config()); + + $this->expectException(ConflictHttpException::class); + $this->expectExceptionMessage('Configuration item already exists.'); + + $this->validator->validate('organisation_name', new UniqueConfigKey()); + } + + public function testValidatePassesIfConfigKeyIsUnique(): void + { + $this->entityManager + ->expects(self::once()) + ->method('find') + ->with(Config::class, 'new_config_key') + ->willReturn(null); + + $this->validator->validate('new_config_key', new UniqueConfigKey()); + + self::assertTrue(true); + } +} diff --git a/tests/Unit/Subscription/Request/PublicSubscriptionRequestTest.php b/tests/Unit/Subscription/Request/PublicSubscriptionRequestTest.php new file mode 100644 index 00000000..e9ee6384 --- /dev/null +++ b/tests/Unit/Subscription/Request/PublicSubscriptionRequestTest.php @@ -0,0 +1,45 @@ +email = ' test@example.com '; + + $dto = $request->getDto(); + + $this->assertSame($request, $dto); + $this->assertSame('test@example.com', $dto->email); + } + + public function testGetDtoKeepsNullEmail(): void + { + $request = new PublicSubscriptionRequest(); + + $dto = $request->getDto(); + + $this->assertSame($request, $dto); + $this->assertNull($dto->email); + $this->assertSame([], $dto->attributes); + } + + public function testSetSubscribePageStoresAndReturnsSelf(): void + { + $request = new PublicSubscriptionRequest(); + $subscribePage = new SubscribePage(); + + $result = $request->setSubscribePage($subscribePage); + + $this->assertSame($request, $result); + $this->assertSame($subscribePage, $request->getSubscribePage()); + } +} diff --git a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php index 523e5904..f579b62a 100644 --- a/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php +++ b/tests/Unit/Subscription/Serializer/SubscribePageNormalizerTest.php @@ -7,6 +7,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Model\SubscribePage; use PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer; +use PhpList\RestBundle\Subscription\Serializer\SubscribePageDataNormalizer; use PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer; use PHPUnit\Framework\TestCase; use stdClass; @@ -16,7 +17,8 @@ class SubscribePageNormalizerTest extends TestCase public function testSupportsNormalization(): void { $adminNormalizer = $this->createMock(AdministratorNormalizer::class); - $normalizer = new SubscribePageNormalizer($adminNormalizer); + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $page = $this->createMock(SubscribePage::class); @@ -49,13 +51,15 @@ public function testNormalizeReturnsExpectedArray(): void $adminNormalizer = $this->createMock(AdministratorNormalizer::class); $adminNormalizer->method('normalize')->with($owner)->willReturn($adminData); - $normalizer = new SubscribePageNormalizer($adminNormalizer); + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $expected = [ 'id' => 42, 'title' => 'welcome@example.org', 'active' => true, 'owner' => $adminData, + 'data' => [], ]; $this->assertSame($expected, $normalizer->normalize($page)); @@ -64,8 +68,8 @@ public function testNormalizeReturnsExpectedArray(): void public function testNormalizeWithInvalidObjectReturnsEmptyArray(): void { $adminNormalizer = $this->createMock(AdministratorNormalizer::class); - $normalizer = new SubscribePageNormalizer($adminNormalizer); - + $subscribePageDataNormalizer = $this->createMock(SubscribePageDataNormalizer::class); + $normalizer = new SubscribePageNormalizer($adminNormalizer, $subscribePageDataNormalizer); $this->assertSame([], $normalizer->normalize(new stdClass())); } } diff --git a/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php b/tests/Unit/Subscription/Serializer/SubscribePagePublicNormalizerTest.php new file mode 100644 index 00000000..5c91d541 --- /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 00000000..04aa38fc --- /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/ListExistsPublicTest.php b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicTest.php new file mode 100644 index 00000000..d60449e2 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicTest.php @@ -0,0 +1,27 @@ +assertSame('strict', $constraint->mode); + $this->assertSame('Subscriber list with id "{{ value }}" does not exists.', $constraint->message); + } + + public function testConstructorAllowsOverridingValues(): void + { + $constraint = new ListExistsPublic(mode: 'relaxed', message: 'Custom message.'); + + $this->assertSame('relaxed', $constraint->mode); + $this->assertSame('Custom message.', $constraint->message); + } +} diff --git a/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php new file mode 100644 index 00000000..3c0c6652 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ListExistsPublicValidatorTest.php @@ -0,0 +1,79 @@ +subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $context = $this->createMock(ExecutionContextInterface::class); + + $this->validator = new ListExistsPublicValidator($this->subscriberListRepository); + $this->validator->initialize($context); + } + + public function testValidateSkipsNull(): void + { + $this->subscriberListRepository->expects($this->never())->method('findBy'); + $this->validator->validate(null, new ListExistsPublic()); + $this->assertTrue(true); + } + + public function testValidateSkipsEmptyString(): void + { + $this->subscriberListRepository->expects($this->never())->method('findBy'); + $this->validator->validate('', new ListExistsPublic()); + $this->assertTrue(true); + } + + public function testValidateThrowsUnexpectedTypeException(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate(123, $this->createMock(Constraint::class)); + } + + public function testValidateThrowsNotFoundExceptionIfListDoesNotExist(): void + { + $this->subscriberListRepository + ->expects($this->once()) + ->method('findBy') + ->with(['id' => 123, 'public' => true]) + ->willReturn([]); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Subscriber list does not exists.'); + + $this->validator->validate('123', new ListExistsPublic()); + } + + public function testValidatePassesIfPublicListExists(): void + { + $subscriberList = $this->createMock(SubscriberList::class); + + $this->subscriberListRepository + ->expects($this->once()) + ->method('findBy') + ->with(['id' => 123, 'public' => true]) + ->willReturn([$subscriberList]); + + $this->validator->validate('123', new ListExistsPublic()); + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionTest.php b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionTest.php new file mode 100644 index 00000000..c6806129 --- /dev/null +++ b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionTest.php @@ -0,0 +1,38 @@ +assertSame('This attribute is required.', $constraint->requiredAttributeMessage); + $this->assertSame('Invalid value.', $constraint->invalidValueMessage); + $this->assertSame('Unknown attribute.', $constraint->unknownAttributeMessage); + $this->assertSame('Invalid email address.', $constraint->invalidEmailMessage); + $this->assertTrue($constraint->rejectUnknownAttributes); + } + + public function testGetTargetsReturnsClassConstraint(): void + { + $constraint = new ValidPublicSubscription(); + + $this->assertSame(Constraint::CLASS_CONSTRAINT, $constraint->getTargets()); + } + + public function testValidatedByReturnsValidatorClass(): void + { + $constraint = new ValidPublicSubscription(); + + $this->assertSame(ValidPublicSubscriptionValidator::class, $constraint->validatedBy()); + } +} diff --git a/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php b/tests/Unit/Subscription/Validator/Constraint/ValidPublicSubscriptionValidatorTest.php new file mode 100644 index 00000000..57a9b619 --- /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()); + } +}