diff --git a/.github/Dockerfile b/.github/Dockerfile new file mode 100644 index 00000000..4b181e87 --- /dev/null +++ b/.github/Dockerfile @@ -0,0 +1,58 @@ +ARG PHP_VERSION +FROM php:${PHP_VERSION}-apache +LABEL maintainer="Adyen " + +ENV MAGENTO_HOST="" \ +DB_SERVER="" \ +DB_PORT=3306 \ +DB_NAME=magento \ +DB_USER=magento \ +DB_PASSWORD=magento \ +DB_PREFIX=m2_ \ +OPENSEARCH_SERVER="" \ +OPENSEARCH_PORT=9200 \ +OPENSEARCH_INDEX_PREFIX=magento2 \ +OPENSEARCH_TIMEOUT=15 \ +ADMIN_NAME=admin \ +ADMIN_LASTNAME=admin \ +ADMIN_EMAIL=admin@example.com \ +ADMIN_USERNAME=admin \ +ADMIN_PASSWORD=admin123 \ +ADMIN_URLEXT=admin \ +MAGENTO_LANGUAGE=en_US \ +MAGENTO_CURRENCY=EUR \ +MAGENTO_TZ=Europe/Amsterdam \ +DEPLOY_SAMPLEDATA=0 \ +USE_SSL=1 + +RUN apt-get update \ + && apt-get install -y libjpeg62-turbo-dev \ + libpng-dev \ + libfreetype6-dev \ + libxml2-dev \ + libzip-dev \ + libssl-dev \ + libxslt-dev \ + default-mysql-client \ + ssl-cert \ + wget \ + cron \ + unzip + +RUN docker-php-ext-configure gd --with-freetype --with-jpeg +RUN docker-php-ext-install -j$(nproc) bcmath gd intl pdo_mysql simplexml soap sockets xsl zip +RUN a2enmod ssl +RUN a2ensite default-ssl.conf #can be removed if not needed +WORKDIR /var/www/html +COPY config/php.ini /usr/local/etc/php/ +COPY scripts/install_magento.sh /tmp/install_magento.sh + +RUN if [ -x "$(command -v apache2-foreground)" ]; then a2enmod rewrite; fi + +ARG MAGENTO_VERSION +ADD "https://github.com/magento/magento2/archive/refs/tags/${MAGENTO_VERSION}.tar.gz" /tmp/magento.tar.gz +ADD "https://github.com/magento/magento2-sample-data/archive/refs/tags/${MAGENTO_VERSION}.tar.gz" /tmp/sample-data.tar.gz + +RUN chmod +x /tmp/install_magento.sh + +CMD ["bash", "/tmp/install_magento.sh"] diff --git a/.github/Makefile b/.github/Makefile new file mode 100644 index 00000000..dbb0df74 --- /dev/null +++ b/.github/Makefile @@ -0,0 +1,64 @@ +# Install N98-Magerun +n98-magerun2.phar: + wget -q https://files.magerun.net/n98-magerun2.phar + chmod +x ./n98-magerun2.phar + +# Check Magento installation +sys-check: n98-magerun2.phar + ./n98-magerun2.phar sys:check + +# Install Magento (without starting Apache) +magento: + sed '/exec /d' /tmp/install_magento.sh | bash + +# Plugin install +install: + composer config --json repositories.local '{"type": "path", "url": "/data/extensions/workdir", "options": { "symlink": false } }' + composer require "adyen/adyen-magento2-expresscheckout:*" + vendor/bin/phpcs --standard=Magento2 --extensions=php,phtml --error-severity=10 --ignore-annotations -n -p vendor/adyen/adyen-magento2-expresscheckout + bin/magento module:enable --all + bin/magento setup:upgrade + bin/magento setup:di:compile + +# Configuration +configure: n98-magerun2.phar + bin/magento config:set payment/adyen_abstract/demo_mode 1 + bin/magento adyen:enablepaymentmethods:run + bin/magento config:set payment/adyen_abstract/merchant_account "${ADYEN_MERCHANT}" + bin/magento config:set payment/adyen_abstract/client_key_test "${ADYEN_CLIENT_KEY}" + bin/magento config:set payment/adyen_abstract/payment_methods_active 1 + bin/magento config:set payment/adyen_googlepay/express_show_on "1,2,3" + bin/magento config:set payment/adyen_applepay/express_show_on "1,2,3" + bin/magento config:set payment/adyen_paypal_express/express_show_on "1,2,3" + ./n98-magerun2.phar config:store:set --encrypt payment/adyen_abstract/notification_password '1234' > /dev/null + ./n98-magerun2.phar config:store:set --encrypt payment/adyen_abstract/api_key_test "${ADYEN_API_KEY}" > /dev/null + +# Clear cache +flush: + bin/magento cache:flush + +# Full plugin setup +plugin: install configure flush + +# Production mode +production: + bin/magento deploy:mode:set production + +# Setup permissions +fs: + find var generated vendor pub/static pub/media app/etc -type f -exec chmod g+w {} + + find var generated vendor pub/static pub/media app/etc -type d -exec chmod g+ws {} + + chmod 777 -R var + chown -R www-data:www-data . + chmod u+x bin/magento + echo "memory_limit = -1" > /usr/local/etc/php/conf.d/memory.ini + +MAGENTO_ROOT=/var/www/html +GRAPHQL_XML=${MAGENTO_ROOT}/dev/tests/api-functional/phpunit_graphql.xml.dist +GRAPHQL_PHP=/data/extensions/workdir/Test/phpunit_graphql.php +GRAPHQL_SUITE=${MAGENTO_ROOT}/vendor/adyen/adyen-magento2-expresscheckout/Test/api-functional/GraphQl + +# GraphQL tests +graphql: + @cd ${MAGENTO_ROOT}/dev/tests/api-functional && \ + ${MAGENTO_ROOT}/vendor/bin/phpunit --prepend ${GRAPHQL_PHP} --configuration ${GRAPHQL_XML} ${GRAPHQL_SUITE} diff --git a/.github/config/php.ini b/.github/config/php.ini new file mode 100644 index 00000000..203e6969 --- /dev/null +++ b/.github/config/php.ini @@ -0,0 +1,8 @@ +memory_limit = 2G +; Error reporting in production mode +error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT +display_errors = Off +display_startup_errors = On +post_max_size = 20M +upload_max_filesize = 20M +date.timezone = Europe/Amsterdam \ No newline at end of file diff --git a/.github/docker-compose.e2e.yml b/.github/docker-compose.e2e.yml new file mode 100644 index 00000000..7983706b --- /dev/null +++ b/.github/docker-compose.e2e.yml @@ -0,0 +1,30 @@ +version: '3' +services: + playwright: + image: mcr.microsoft.com/playwright:v1.49.0 + shm_size: 1gb + ipc: host + cap_add: + - SYS_ADMIN + networks: + - backend + environment: + - INTEGRATION_TESTS_BRANCH + - MAGENTO_BASE_URL + - MAGENTO_ADMIN_USERNAME + - MAGENTO_ADMIN_PASSWORD + - PAYPAL_USERNAME + - PAYPAL_PASSWORD + - ADYEN_API_KEY + - ADYEN_CLIENT_KEY + - ADYEN_MERCHANT + - GOOGLE_USERNAME + - GOOGLE_PASSWORD + - WEBHOOK_USERNAME + - WEBHOOK_PASSWORD + - CI + volumes: + - ./scripts/e2e.sh:/e2e.sh + - ../test-report:/tmp/test-report +networks: + backend: diff --git a/.github/docker-compose.yml b/.github/docker-compose.yml new file mode 100644 index 00000000..7cac6d40 --- /dev/null +++ b/.github/docker-compose.yml @@ -0,0 +1,65 @@ +version: '3' + +services: + db: + image: mariadb:10.4 + container_name: mariadb + networks: + - backend + environment: + MARIADB_ROOT_PASSWORD: root_password + MARIADB_DATABASE: magento + MARIADB_USER: magento + MARIADB_PASSWORD: magento + opensearch: + image: bitnami/opensearch:2 + container_name: opensearch-container + networks: + - backend + ports: + - 9200:9200 + - 9300:9300 + environment: + - "discovery.type=single-node" + - "ES_JAVA_OPTS=-Xms750m -Xmx750m" + web: + build: + context: . + args: + - PHP_VERSION=${PHP_VERSION} + - MAGENTO_VERSION=${MAGENTO_VERSION} + container_name: magento2-container + extra_hosts: + - "magento2.test.com:127.0.0.1" + networks: + backend: + aliases: + - magento2.test.com + environment: + DB_SERVER: mariadb + OPENSEARCH_SERVER: opensearch-container + MAGENTO_HOST: magento2.test.com + VIRTUAL_HOST: magento2.test.com + COMPOSER_MEMORY_LIMIT: -1 + DEPLOY_SAMPLEDATA: + DONATION_ACCOUNT: + ADMIN_USERNAME: + ADMIN_PASSWORD: + ADYEN_MERCHANT: + ADYEN_API_KEY: + ADYEN_CLIENT_KEY: + PHP_VERSION: + MAGENTO_VERSION: + depends_on: + - db + - opensearch + volumes: + - ../:/data/extensions/workdir + - ./Makefile:/var/www/html/Makefile + - composer:/usr/local/bin + - magento:/var/www/html +networks: + backend: +volumes: + magento: + composer: diff --git a/.github/scripts/e2e.sh b/.github/scripts/e2e.sh new file mode 100755 index 00000000..6fa23061 --- /dev/null +++ b/.github/scripts/e2e.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Base configuration and installation +set -euo pipefail +cd /tmp +git clone https://github.com/Adyen/adyen-integration-tools-tests.git +cd adyen-integration-tools-tests +git checkout $INTEGRATION_TESTS_BRANCH +rm -rf package-lock.json +npm i +npx playwright install + +option="$1" + +# Run the desired group of tests +case $option in + "standard") + echo "Running Standard Set of E2E Tests." + npm run test:ci:magento + ;; + "express-checkout") + echo "Running Express Checkout E2E Tests." + npm run test:ci:magento:express-checkout + ;; + "all") + echo "Running All Magento E2E Tests" + npm run test:ci:magento:all + ;; +esac diff --git a/.github/scripts/install_magento.sh b/.github/scripts/install_magento.sh new file mode 100644 index 00000000..ad81930b --- /dev/null +++ b/.github/scripts/install_magento.sh @@ -0,0 +1,158 @@ +#!/bin/bash + +MAGENTO_INSTALL_ARGS=""; + +if [ "$DB_SERVER" != "" ]; then + RET=1 + while [ $RET -ne 0 ]; do + echo "Checking if $DB_SERVER is available." + mysql -h "$DB_SERVER" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASSWORD" -e "status" >/dev/null 2>&1 + RET=$? + + if [ $RET -ne 0 ]; then + echo "Connection to MySQL/MariaDB is pending." + sleep 5 + fi + done + echo "DB server $DB_SERVER is available." +else + echo "MySQL/MariaDB server is not defined!" + exit 1 +fi + +if [ "$OPENSEARCH_SERVER" != "" ]; then + MAGENTO_INSTALL_ARGS=$(echo \ + --search-engine="opensearch" \ + --opensearch-host="$OPENSEARCH_SERVER" \ + --opensearch-port="$OPENSEARCH_PORT" \ + --opensearch-index-prefix="$OPENSEARCH_INDEX_PREFIX" \ + --opensearch-timeout="$OPENSEARCH_TIMEOUT") + RET=1 + while [ $RET -ne 0 ]; do + echo "Checking if $OPENSEARCH_SERVER is available." + curl -XGET "$OPENSEARCH_SERVER:$OPENSEARCH_PORT/_cat/health?v&pretty" >/dev/null 2>&1 + RET=$? + + if [ $RET -ne 0 ]; then + echo "Connection to OpenSearch is pending." + sleep 5 + fi + done + echo "OpenSearch server $OPENSEARCH_SERVER is available." +fi + +if [[ -e /tmp/magento.tar.gz ]]; then + mv /tmp/magento.tar.gz /var/www/html +else + echo "Magento 2 tar is already moved to /var/www/html" +fi + +if [[ -e /tmp/sample-data.tar.gz ]]; then + mv /tmp/sample-data.tar.gz /var/www +else + echo "Magento 2 sample data tar is alread moved to /var/www" +fi + +if [[ -e /var/www/html/pub/index.php ]]; then + echo "Already extracted Magento" +else + tar -xf magento.tar.gz --strip-components 1 + rm magento.tar.gz +fi + +if [[ -e /usr/local/bin/composer ]]; then + echo "Composer already exists" +else + php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" + php composer-setup.php --quiet + rm composer-setup.php + mv composer.phar /usr/local/bin/composer +fi + +if [[ -d /var/www/html/vendor/magento ]]; then + echo "Magento is already installed." +else + composer install -n + + find var generated vendor pub/static pub/media app/etc -type f -exec chmod g+w {} + + find var generated vendor pub/static pub/media app/etc -type d -exec chmod g+ws {} + + chown -R www-data:www-data . + chmod u+x bin/magento + + bin/magento setup:install \ + --base-url="http://$MAGENTO_HOST" \ + --db-host="$DB_SERVER:$DB_PORT" \ + --db-name="$DB_NAME" \ + --db-user="$DB_USER" \ + --db-password="$DB_PASSWORD" \ + --db-prefix="$DB_PREFIX" \ + --admin-firstname="$ADMIN_NAME" \ + --admin-lastname="$ADMIN_LASTNAME" \ + --admin-email="$ADMIN_EMAIL" \ + --admin-user="$ADMIN_USERNAME" \ + --admin-password="$ADMIN_PASSWORD" \ + --backend-frontname="$ADMIN_URLEXT" \ + --language=en_US \ + --currency=EUR \ + --timezone=Europe/Amsterdam \ + --use-rewrites=1 \ + --cleanup-database \ + $MAGENTO_INSTALL_ARGS; + + bin/magento setup:di:compile + bin/magento setup:static-content:deploy -f + bin/magento indexer:reindex + bin/magento deploy:mode:set developer + bin/magento maintenance:disable + bin/magento cron:install + + echo "Installation completed" +fi + +if [ "$DEPLOY_SAMPLEDATA" -eq 1 ]; then + if [[ -e ../sample-data.tar.gz ]]; then + echo "Installing sample data" + mkdir ../sample-data + tar -xf ../sample-data.tar.gz --strip-components 1 -C ../sample-data + rm ../sample-data.tar.gz + php -f ../sample-data/dev/tools/build-sample-data.php -- --ce-source="/var/www/html" + bin/magento setup:upgrade + else + echo "Sample data is already installed" + fi +fi + +ISSET_USE_SSL=$(bin/magento config:show web/secure/use_in_frontend) + +if [ "$USE_SSL" -eq 1 ]; then + if [ "${ISSET_USE_SSL:-0}" -eq 1 ]; then + echo "Use SSL is set, but SSL is already enabled." + else + bin/magento setup:store-config:set \ + --base-url-secure="https://$MAGENTO_HOST" \ + --use-secure=1 \ + --use-secure-admin=1 + echo "SSL for Magento is configured." + fi +else + echo "Use SSL is not set, skipping." +fi + +grep "ServerName" /etc/apache2/apache2.conf >/dev/null 2>&1 +SERVERNAME_EXISTS=$? + +if [ $SERVERNAME_EXISTS -eq 0 ]; then + echo "ServerName is already added in Apache config." +else + echo "ServerName $MAGENTO_HOST" >>/etc/apache2/apache2.conf + echo "ServerName is added to Apache config." +fi + +if [[ -e /tmp/enable_debugging.sh ]]; then + echo "Configuring Xdebug" + /tmp/enable_debugging.sh +fi + +/etc/init.d/cron start + +exec apache2-foreground diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f4949b24..89be9a49 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -34,11 +34,11 @@ jobs: - name: Prepare the next main release uses: Adyen/release-automation-action@v1.3.1 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.ADYEN_RELEASE_AUTOMATION_USER_TOKEN }} develop-branch: main version-files: composer.json release-title: Adyen Magento 2 Express Checkout Module pre-release: ${{ inputs.pre-release || false }} # For a manual Github release github-release: ${{ inputs.github-release || false }} - separator: .pre.beta \ No newline at end of file + separator: .pre.beta diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e0f9ca1b..c3fd85f5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,17 +1,72 @@ -name: Adyen Magento 2 Express Checkout Plugin E2E Trigger Workflow -run-name: Headless E2E tests for ${{ github.event.pull_request.head.ref}} +name: E2E test workflow +run-name: Headless E2E tests for ${{ github.event.pull_request.head.ref }} on: pull_request: - types: [opened, synchronize] - branches: [main, develop] + branches: [main] jobs: - test: - runs-on: ubuntu-latest - if: ${{ github.actor != 'renovate[bot]' || github.actor != 'lgtm-com[bot]' }} + build: + strategy: + matrix: + php-version: ["8.2"] + magento-version: ["2.4.6-p8"] + runs-on: + group: larger-runners + labels: ubuntu-latest-8-cores + timeout-minutes: 10 env: - GITHUB_TOKEN: ${{ secrets.ADYEN_AUTOMATION_BOT_TEST_ACCESS_TOKEN }} + PHP_VERSION: ${{ matrix.php-version }} + MAGENTO_VERSION: ${{ matrix.magento-version }} + ADYEN_API_KEY: ${{secrets.ADYEN_API_KEY}} + ADYEN_CLIENT_KEY: ${{secrets.ADYEN_CLIENT_KEY}} + ADYEN_MERCHANT: ${{secrets.ADYEN_MERCHANT}} + DEPLOY_SAMPLEDATA: 1 steps: - - name: Run E2E Tests - run: gh workflow run e2e-test-dispatch.yml -R Adyen/adyen-magento2 -F expressBranch=${{ github.event.pull_request.head.ref }} -F testGroup="express-checkout" + - uses: actions/checkout@v4 + + - name: Install Magento + run: docker compose -f .github/docker-compose.yml run --rm web make magento + + - name: Start web server in background + run: docker compose -f .github/docker-compose.yml up -d web + + - name: Setup permissions + run: docker exec magento2-container make fs + + - name: Check install + run: docker exec magento2-container make sys-check + + - name: Install plugin + run: docker exec -u www-data magento2-container make plugin + + - name: Kill Cron Jobs + run: docker exec magento2-container /etc/init.d/cron stop + + - name: Switch to production mode + run: docker exec -u www-data magento2-container make production + + - name: Setup permissions + run: docker exec magento2-container make fs + + - name: Run E2E tests + run: docker compose -f .github/docker-compose.e2e.yml run --rm playwright /e2e.sh express-checkout + env: + INTEGRATION_TESTS_BRANCH: ${{ env.TEST_BRANCH }} + MAGENTO_ADMIN_USERNAME: ${{secrets.MAGENTO_ADMIN_USERNAME}} + MAGENTO_ADMIN_PASSWORD: ${{secrets.MAGENTO_ADMIN_PASSWORD}} + MAGENTO_BASE_URL: ${{secrets.MAGENTO_BASE_URL}} + PAYPAL_USERNAME: ${{secrets.PLAYWRIGHT_PAYPAL_USERNAME}} + PAYPAL_PASSWORD: ${{secrets.PLAYWRIGHT_PAYPAL_PASSWORD}} + GOOGLE_USERNAME: ${{secrets.PLAYWRIGHT_GOOGLE_USERNAME}} + GOOGLE_PASSWORD: ${{secrets.PLAYWRIGHT_GOOGLE_PASSWORD}} + WEBHOOK_USERNAME: admin + WEBHOOK_PASSWORD: 1234 + CI: TRUE + + - name: Archive test result artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: html-report + path: test-report diff --git a/.github/workflows/graphql-test.yml b/.github/workflows/graphql-test.yml new file mode 100644 index 00000000..2d06971c --- /dev/null +++ b/.github/workflows/graphql-test.yml @@ -0,0 +1,43 @@ +name: GraphQL Tests +on: + pull_request: + +jobs: + build: + strategy: + matrix: + php-version: [ "8.2" ] + magento-version: [ "2.4.6-p8" ] + runs-on: ubuntu-latest + env: + PHP_VERSION: ${{ matrix.php-version }} + MAGENTO_VERSION: ${{ matrix.magento-version }} + ADMIN_USERNAME: ${{secrets.MAGENTO_ADMIN_USERNAME}} + ADMIN_PASSWORD: ${{secrets.MAGENTO_ADMIN_PASSWORD}} + ADYEN_MERCHANT: ${{secrets.ADYEN_MERCHANT}} + ADYEN_API_KEY: ${{secrets.ADYEN_API_KEY}} + ADYEN_CLIENT_KEY: ${{secrets.ADYEN_CLIENT_KEY}} + DEPLOY_SAMPLEDATA: 1 + + steps: + - uses: actions/checkout@v4 + + - name: Install Magento + run: docker compose -f .github/docker-compose.yml run --rm web make magento + + - name: Start web server in background + run: docker compose -f .github/docker-compose.yml up -d web + + - name: Setup permissions + run: docker exec magento2-container make fs + + - name: Check install + run: docker exec magento2-container make sys-check + + - name: Install plugin + run: docker exec -u www-data magento2-container make plugin + + - run: docker exec magento2-container /etc/init.d/cron stop + + - name: Run GraphQL tests + run: docker exec magento2-container make graphql diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d97912fc..f079a8dc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,28 +1,49 @@ on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + name: Main Workflow jobs: - run: - name: Run + build: runs-on: ubuntu-latest strategy: matrix: - php-version: [ 7.4, 8.0, 8.1 ] + php-version: [8.2,8.3] steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - tools: composer:v1 + tools: composer:v2 - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - name: Test plugin installation + run: | + echo "{\"http-basic\":{\"repo.magento.com\":{\"username\":\"${MAGENTO_USERNAME}\",\"password\":\"${MAGENTO_PASSWORD}\"}}}" > auth.json + composer install --prefer-dist + env: + CI: true + MAGENTO_USERNAME: ${{ secrets.MAGENTO_USERNAME }} + MAGENTO_PASSWORD: ${{ secrets.MAGENTO_PASSWORD }} + + - name: Run PHPUnit + run: vendor/bin/phpunit --coverage-clover=build/clover.xml --log-junit=build/tests-log.xml -c Test/phpunit.xml Test/Unit + + - name: Fix code coverage paths + run: sed -i "s;`pwd`/;;g" build/*.xml + + - name: SonarCloud Scan + if: ${{ env.SONAR_TOKEN }} + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: TruffleHog OSS uses: trufflesecurity/trufflehog@main diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 4d7199eb..00000000 --- a/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# OS -.DS_Store -Thumbs.db - -# IDE and local environment -.vscode/ -.vagrant/ -.idea/ - -# Packages -vendor/ diff --git a/Block/Paypal/Shortcut/Button.php b/Block/Paypal/Shortcut/Button.php index e1e0add4..d580d997 100644 --- a/Block/Paypal/Shortcut/Button.php +++ b/Block/Paypal/Shortcut/Button.php @@ -18,5 +18,7 @@ class Button extends AbstractButton implements ShortcutInterface { + const PAYPAL_EXPRESS_METHOD_NAME = 'adyen_paypal_express'; + const PAYPAL_METHOD_NAME = 'adyen_paypal'; const PAYMENT_METHOD_VARIANT = 'paypal_express'; } diff --git a/Model/AdyenInitPayments.php b/Model/AdyenInitPayments.php index 08c18c34..207cb1d3 100644 --- a/Model/AdyenInitPayments.php +++ b/Model/AdyenInitPayments.php @@ -161,8 +161,10 @@ public function execute( try { $response = $this->transactionPaymentClient->placeRequest($transfer); + $paymentsResponse = $this->returnFirstTransactionPaymentResponse($response); + return json_encode( - $this->paymentResponseHandler->formatPaymentResponse($response['resultCode'], $response['action']) + $this->paymentResponseHandler->formatPaymentResponse($paymentsResponse['resultCode'], $paymentsResponse['action']) ); } catch (Exception $e) { throw new ClientException( @@ -200,4 +202,20 @@ protected function buildPaymentsRequest(Quote $quote, array $stateData): array return array_merge($request, $stateData); } + + /** + * This method cleans up the unnecessary gift card response data + * and returns the actual `/payments` API response. + * + * @param array $response + * @return array + */ + private function returnFirstTransactionPaymentResponse(array $response): array + { + if (array_key_exists('hasOnlyGiftCards', $response)) { + unset($response['hasOnlyGiftCards']); + } + + return reset($response); + } } diff --git a/Model/Resolver/AdyenExpressInitPaymentsResolver.php b/Model/Resolver/AdyenExpressInitPaymentsResolver.php new file mode 100644 index 00000000..ea41c1bd --- /dev/null +++ b/Model/Resolver/AdyenExpressInitPaymentsResolver.php @@ -0,0 +1,100 @@ + + */ +declare(strict_types=1); + +namespace Adyen\ExpressCheckout\Model\Resolver; + +use Adyen\ExpressCheckout\Api\AdyenInitPaymentsInterface; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Model\Resolver\DataProvider\GetAdyenPaymentStatus; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\Value; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Quote\Model\QuoteIdMaskFactory; + +class AdyenExpressInitPaymentsResolver implements ResolverInterface +{ + /** + * @param AdyenInitPaymentsInterface $adyenInitPaymentsApi + * @param ValueFactory $valueFactory + * @param QuoteIdMaskFactory $quoteMaskFactory + * @param AdyenLogger $adyenLogger + * @param GetAdyenPaymentStatus $adyenPaymentStatusDataProvider + */ + public function __construct( + public AdyenInitPaymentsInterface $adyenInitPaymentsApi, + public ValueFactory $valueFactory, + public QuoteIdMaskFactory $quoteMaskFactory, + public AdyenLogger $adyenLogger, + public GetAdyenPaymentStatus $adyenPaymentStatusDataProvider + ) { } + + /** + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * @return Value + * @throws GraphQlInputException + * @throws LocalizedException + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): Value + { + if (empty($args['stateData'])) { + throw new GraphQlInputException(__('Required parameter "stateData" is missing!')); + } + + if (empty($args['adyenMaskedQuoteId']) && empty($args['adyenCartId'])) { + throw new GraphQlInputException( + __('Either one of `adyenCartId` or `adyenMaskedQuoteId` is required!') + ); + } + + try { + $stateData = $args['stateData']; + $adyenMaskedQuoteId = $args['adyenMaskedQuoteId'] ?? null; + $adyenCartId = $args['adyenCartId'] ?? null; + + if (isset($adyenCartId)) { + $quoteIdMask = $this->quoteMaskFactory->create()->load( + $adyenCartId, + 'masked_id' + ); + $quoteId = (int) $quoteIdMask->getQuoteId(); + } else { + $quoteId = null; + } + + $provider = $this->adyenInitPaymentsApi; + + $result = function () use ($stateData, $adyenMaskedQuoteId, $quoteId, $provider) { + return $this->adyenPaymentStatusDataProvider->formatResponse( + json_decode($provider->execute($stateData, $quoteId, $adyenMaskedQuoteId), true) + ); + }; + + return $this->valueFactory->create($result); + } catch (Exception $e) { + $errorMessage = "An error occurred during initializing API call to `/payments` endpoint!"; + $logMessage = sprintf("%s: %s", $errorMessage, $e->getMessage()); + $this->adyenLogger->error($logMessage); + + throw new LocalizedException(__($errorMessage)); + } + } +} diff --git a/Model/Resolver/AdyenExpressPaypalUpdateOrderResolver.php b/Model/Resolver/AdyenExpressPaypalUpdateOrderResolver.php new file mode 100644 index 00000000..0a0345e2 --- /dev/null +++ b/Model/Resolver/AdyenExpressPaypalUpdateOrderResolver.php @@ -0,0 +1,103 @@ + + */ +declare(strict_types=1); + +namespace Adyen\ExpressCheckout\Model\Resolver; + +use Adyen\ExpressCheckout\Api\AdyenPaypalUpdateOrderInterface; +use Adyen\ExpressCheckout\Helper\PaypalUpdateOrder; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Model\Resolver\DataProvider\GetAdyenPaymentStatus; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\Value; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Quote\Model\QuoteIdMaskFactory; + +class AdyenExpressPaypalUpdateOrderResolver implements ResolverInterface +{ + /** + * @param AdyenPaypalUpdateOrderInterface $adyenPaypalUpdateOrderApi + * @param ValueFactory $valueFactory + * @param QuoteIdMaskFactory $quoteMaskFactory + * @param AdyenLogger $adyenLogger + * @param PaypalUpdateOrder $paypalUpdateOrderHelper + */ + public function __construct( + public AdyenPaypalUpdateOrderInterface $adyenPaypalUpdateOrderApi, + public ValueFactory $valueFactory, + public QuoteIdMaskFactory $quoteMaskFactory, + public AdyenLogger $adyenLogger, + public PaypalUpdateOrder $paypalUpdateOrderHelper + ) { } + + /** + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * @return Value + * @throws GraphQlInputException + * @throws LocalizedException + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): Value + { + if (empty($args['paymentData'])) { + throw new GraphQlInputException(__('Required parameter "paymentData" is missing!')); + } + + if (empty($args['adyenMaskedQuoteId']) && empty($args['adyenCartId'])) { + throw new GraphQlInputException( + __('Either one of `adyenCartId` or `adyenMaskedQuoteId` is required!') + ); + } + + try { + $paymentData = $args['paymentData']; + $deliveryMethods = $args['deliveryMethods'] ?? []; + $adyenMaskedQuoteId = $args['adyenMaskedQuoteId'] ?? null; + $adyenCartId = $args['adyenCartId'] ?? null; + + if (isset($adyenCartId)) { + $quoteIdMask = $this->quoteMaskFactory->create()->load( + $adyenCartId, + 'masked_id' + ); + $quoteId = (int) $quoteIdMask->getQuoteId(); + } else { + $quoteId = null; + } + + $provider = $this->adyenPaypalUpdateOrderApi; + + $result = function () use ($paymentData, $deliveryMethods, $adyenMaskedQuoteId, $quoteId, $provider) { + return json_decode( + $provider->execute($paymentData, $quoteId, $adyenMaskedQuoteId, json_encode($deliveryMethods)), + true + ); + }; + + return $this->valueFactory->create($result); + } catch (Exception $e) { + $errorMessage = "An error occurred during initializing API call to `/paypal/updateOrder` endpoint!"; + $logMessage = sprintf("%s: %s", $errorMessage, $e->getMessage()); + $this->adyenLogger->error($logMessage); + + throw new LocalizedException(__($errorMessage)); + } + } +} diff --git a/Model/Resolver/ExpressActivateResolver.php b/Model/Resolver/ExpressActivateResolver.php new file mode 100644 index 00000000..8ba68ff1 --- /dev/null +++ b/Model/Resolver/ExpressActivateResolver.php @@ -0,0 +1,89 @@ + + */ +declare(strict_types=1); + +namespace Adyen\ExpressCheckout\Model\Resolver; + +use Adyen\ExpressCheckout\Model\ExpressActivate; +use Adyen\Payment\Logger\AdyenLogger; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\Value; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Quote\Model\QuoteIdMaskFactory; + +class ExpressActivateResolver implements ResolverInterface +{ + /** + * @param ExpressActivate $expressActivateApi + * @param ValueFactory $valueFactory + * @param QuoteIdMaskFactory $quoteMaskFactory + * @param AdyenLogger $adyenLogger + */ + public function __construct( + public ExpressActivate $expressActivateApi, + public ValueFactory $valueFactory, + public QuoteIdMaskFactory $quoteMaskFactory, + public AdyenLogger $adyenLogger + ) { } + + /** + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * @return Value + * @throws GraphQlInputException + * @throws LocalizedException + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): Value + { + if (empty($args['adyenMaskedQuoteId'])) { + throw new GraphQlInputException(__('Required parameter "adyenMaskedQuoteId" is missing!')); + } + + try { + $adyenMaskedQuoteId = $args['adyenMaskedQuoteId']; + $adyenCartId = $args['adyenCartId'] ?? null; + + if (isset($adyenCartId)) { + $quoteIdMask = $this->quoteMaskFactory->create()->load( + $adyenCartId, + 'masked_id' + ); + $quoteId = (int) $quoteIdMask->getQuoteId(); + } else { + $quoteId = null; + } + + $provider = $this->expressActivateApi; + + $result = function () use ($adyenMaskedQuoteId, $quoteId, $provider) { + $provider->execute($adyenMaskedQuoteId, $quoteId); + return true; + }; + + return $this->valueFactory->create($result); + } catch (Exception $e) { + $errorMessage = "An error occurred while activating the express quote"; + $logMessage = sprintf("%s: %s", $errorMessage, $e->getMessage()); + $this->adyenLogger->error($logMessage); + + throw new LocalizedException(__($errorMessage)); + } + } +} diff --git a/Model/Resolver/ExpressCancelResolver.php b/Model/Resolver/ExpressCancelResolver.php new file mode 100644 index 00000000..017bb45a --- /dev/null +++ b/Model/Resolver/ExpressCancelResolver.php @@ -0,0 +1,84 @@ + + */ +declare(strict_types=1); + +namespace Adyen\ExpressCheckout\Model\Resolver; + +use Adyen\ExpressCheckout\Model\ExpressCancel; +use Adyen\Payment\Logger\AdyenLogger; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\Value; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Quote\Model\QuoteIdMaskFactory; + +class ExpressCancelResolver implements ResolverInterface +{ + /** + * @param ExpressCancel $expressCancelApi + * @param ValueFactory $valueFactory + * @param QuoteIdMaskFactory $quoteMaskFactory + * @param AdyenLogger $adyenLogger + */ + public function __construct( + public ExpressCancel $expressCancelApi, + public ValueFactory $valueFactory, + public QuoteIdMaskFactory $quoteMaskFactory, + public AdyenLogger $adyenLogger + ) { } + + /** + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * @return Value + * @throws GraphQlInputException + * @throws LocalizedException + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): Value + { + if (empty($args['adyenMaskedQuoteId'])) { + throw new GraphQlInputException(__('Required parameter "adyenMaskedQuoteId" is missing!')); + } + + try { + // Extract unmasked quote id + $adyenMaskedQuoteId = $args['adyenMaskedQuoteId']; + $quoteIdMask = $this->quoteMaskFactory->create()->load( + $adyenMaskedQuoteId, + 'masked_id' + ); + $quoteId = (int) $quoteIdMask->getQuoteId(); + + $provider = $this->expressCancelApi; + + $result = function () use ($quoteId, $provider) { + $provider->execute($quoteId); + return true; + }; + + return $this->valueFactory->create($result); + } catch (Exception $e) { + $errorMessage = "An error occurred while cancelling the express quote"; + $logMessage = sprintf("%s: %s", $errorMessage, $e->getMessage()); + $this->adyenLogger->error($logMessage); + + throw new LocalizedException(__($errorMessage)); + } + } +} diff --git a/Model/Resolver/ExpressInitResolver.php b/Model/Resolver/ExpressInitResolver.php new file mode 100644 index 00000000..d86e7325 --- /dev/null +++ b/Model/Resolver/ExpressInitResolver.php @@ -0,0 +1,98 @@ + + */ +declare(strict_types=1); + +namespace Adyen\ExpressCheckout\Model\Resolver; + +use Adyen\ExpressCheckout\Api\Data\ProductCartParamsInterfaceFactory; +use Adyen\ExpressCheckout\Model\ExpressInit; +use Adyen\Payment\Logger\AdyenLogger; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\Value; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Quote\Model\QuoteIdMaskFactory; + +class ExpressInitResolver implements ResolverInterface +{ + /** + * @param ExpressInit $expressInitApi + * @param ProductCartParamsInterfaceFactory $productCartParamsFactory + * @param ValueFactory $valueFactory + * @param QuoteIdMaskFactory $quoteMaskFactory + * @param AdyenLogger $adyenLogger + */ + public function __construct( + public ExpressInit $expressInitApi, + public ProductCartParamsInterfaceFactory $productCartParamsFactory, + public ValueFactory $valueFactory, + public QuoteIdMaskFactory $quoteMaskFactory, + public AdyenLogger $adyenLogger + ) { } + + /** + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * @return Value + * @throws GraphQlInputException + * @throws LocalizedException + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): Value + { + if (empty($args['productCartParams'])) { + throw new GraphQlInputException(__('Required parameter "productCartParams" is missing!')); + } + + $productCartParamsDecoded = json_decode($args['productCartParams'], true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new GraphQlInputException(__('Invalid JSON provided for "productCartParams"!')); + } + + try { + $productCartParams = $this->productCartParamsFactory->create(); + $productCartParams->setData($productCartParamsDecoded); + + $adyenCartId = $args['adyenCartId'] ?? null; + $adyenMaskedQuoteId = $args['adyenMaskedQuoteId'] ?? null; + $provider = $this->expressInitApi; + + if (isset($adyenCartId)) { + $quoteIdMask = $this->quoteMaskFactory->create()->load( + $adyenCartId, + 'masked_id' + ); + $quoteId = (int) $quoteIdMask->getQuoteId(); + } else { + $quoteId = null; + } + + $result = function () use ($productCartParams, $quoteId, $adyenMaskedQuoteId, $provider) { + return $provider->execute($productCartParams, $quoteId, $adyenMaskedQuoteId); + }; + + return $this->valueFactory->create($result); + } catch (Exception $e) { + $errorMessage = "An error occurred while initiating the express quote"; + $logMessage = sprintf("%s: %s", $errorMessage, $e->getMessage()); + $this->adyenLogger->error($logMessage); + + throw new LocalizedException(__($errorMessage)); + } + } +} diff --git a/Observer/SubmitQuoteObserver.php b/Observer/SubmitQuoteObserver.php deleted file mode 100644 index d1880067..00000000 --- a/Observer/SubmitQuoteObserver.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - -declare(strict_types=1); - -namespace Adyen\ExpressCheckout\Observer; - -use Magento\Framework\Event\ObserverInterface; -use Magento\Framework\Event\Observer; -use Magento\Sales\Model\Order; - -class SubmitQuoteObserver implements ObserverInterface -{ - /** - * Execute method for the observer. - * - * @param Observer $observer - * @return void - */ - public function execute(Observer $observer) - { - $order = $observer->getEvent()->getOrder(); - $payment = $order->getPayment(); - - if ($payment->getMethod() == 'adyen_paypal_express') { - $payment->setMethod('adyen_paypal'); - } - - $order->setState(Order::STATE_NEW); - $order->setStatus($order->getConfig()->getStateDefaultStatus(Order:: STATE_NEW)); - $order->save(); - } -} diff --git a/Observer/UpdatePaypalOrderStatus.php b/Observer/UpdatePaypalOrderStatus.php new file mode 100644 index 00000000..3e592dce --- /dev/null +++ b/Observer/UpdatePaypalOrderStatus.php @@ -0,0 +1,45 @@ + + */ + +declare(strict_types=1); + +namespace Adyen\ExpressCheckout\Observer; + +use Adyen\ExpressCheckout\Block\Paypal\Shortcut\Button; +use Magento\Framework\Event\ObserverInterface; +use Magento\Framework\Event\Observer; +use Magento\Sales\Model\Order; + +class UpdatePaypalOrderStatus implements ObserverInterface +{ + /** + * This observer is responsible for setting the payment method as `adyen_paypal` for express payments. + * In addition, order status is set to `pending` with state `new` since order state is processing + * by default as `/orders` endpoint is used to create the order while making express PayPal payments. + * + * @param Observer $observer + * @return void + */ + public function execute(Observer $observer): void + { + $order = $observer->getEvent()->getOrder(); + $payment = $order->getPayment(); + + if ($payment->getMethod() === Button::PAYPAL_EXPRESS_METHOD_NAME) { + $payment->setMethod(Button::PAYPAL_METHOD_NAME); + + $order->setState(Order::STATE_NEW); + $order->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_NEW)); + $order->save(); + } + } +} diff --git a/README.md b/README.md index a2565e61..18bce3df 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This module gives our customers the ability to use Apple Pay and Google Pay as a ## Features * Apple Pay express checkout options on the product page, mini cart and cart. * Google Pay express checkout options on the product page, mini cart and cart. +* PayPal express checkout options on the product page, mini cart and cart. More express flows will be coming in 2023. diff --git a/Test/Unit/Model/Resolver/AbstractAdyenResolverTestCase.php b/Test/Unit/Model/Resolver/AbstractAdyenResolverTestCase.php new file mode 100644 index 00000000..44bb1bbd --- /dev/null +++ b/Test/Unit/Model/Resolver/AbstractAdyenResolverTestCase.php @@ -0,0 +1,169 @@ + + */ + +namespace Adyen\ExpressCheckout\Test\Unit\Model\Resolver; + +use Adyen\ExpressCheckout\Exception\ExpressInitException; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\Value; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\GraphQl\Model\Query\Context; +use Magento\Quote\Model\QuoteIdMask; +use Magento\Quote\Model\QuoteIdMaskFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use PHPUnit\Framework\MockObject\MockObject; + +abstract class AbstractAdyenResolverTestCase extends AbstractAdyenTestCase +{ + protected ?ResolverInterface $resolver; + protected MockObject&Field $fieldMock; + protected MockObject&Context $contextMock; + protected MockObject&ResolveInfo $infoMock; + protected MockObject&ValueFactory $valueFactoryMock; + protected MockObject&QuoteIdMaskFactory $quoteIdMaskFactoryMock; + protected MockObject&AdyenLogger $loggerMock; + + /** + * Data provider for abstract test case testResolverShouldThrowExceptionWithEmptyArgument() + * + * @return array[] + */ + abstract protected static function emptyArgumentAssertionDataProvider(): array; + + /** + * Data provider for abstract test case testSuccessfulResolving() + * + * @return array + */ + abstract protected static function successfulResolverDataProvider(): array; + + /** + * Data provider for abstract test case testMissingQuoteShouldThrowException() + * + * @return array + */ + abstract protected static function missingQuoteAssertionDataProvider(): array; + + public function setUp(): void + { + $this->fieldMock = $this->createMock(Field::class); + $this->contextMock = $this->createMock(Context::class); + $this->infoMock = $this->createMock(ResolveInfo::class); + $this->valueFactoryMock = $this->createMock(ValueFactory::class); + $this->quoteIdMaskFactoryMock = $this->createGeneratedMock(QuoteIdMaskFactory::class, [ + 'create' + ]); + $this->loggerMock = $this->createMock(AdyenLogger::class); + } + + /** + * Reset the target class after each test + * + * @return void + */ + public function tearDown(): void + { + $this->resolver = null; + } + + /** + * Assets successful generation of the Value class after resolving + * the mutation with correct set of inputs. + * + * @dataProvider successfulResolverDataProvider + * + * @return void + * @throws Exception + */ + public function testSuccessfulResolving($args) + { + $returnValueMock = $this->createMock(Value::class); + $this->valueFactoryMock->method('create')->willReturn($returnValueMock); + + $quoteIdMaskMock = $this->createGeneratedMock(QuoteIdMask::class, [ + 'load', + 'getQuoteId' + ]); + $quoteIdMaskMock->method('load')->willReturn($quoteIdMaskMock); + $quoteIdMaskMock->method('getQuoteId')->willReturn(1); + $this->quoteIdMaskFactoryMock->method('create')->willReturn($quoteIdMaskMock); + + $result = $this->resolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + [], + $args + ); + + $this->assertInstanceOf(Value::class, $result); + } + + /** + * @dataProvider emptyArgumentAssertionDataProvider + * + * Assets expect exception if the required parameter is an empty string. + * `emptyArgumentAssertionDataProvider()` data provider should be implemented in the test class. + * + * @return void + * @throws GraphQlInputException|Exception + */ + public function testResolverShouldThrowExceptionWithEmptyArgument(array $args) + { + $this->expectException(GraphQlInputException::class); + + $this->resolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + [], + $args + ); + } + + /** + * Asserts exception if the quote entity not found + * + * @dataProvider missingQuoteAssertionDataProvider + * + * @return void + * @throws Exception + */ + public function testMissingQuoteShouldThrowException($args) + { + $this->expectException(LocalizedException::class); + + $quoteIdMaskMock = $this->createGeneratedMock(QuoteIdMask::class, [ + 'load', + 'getQuoteId' + ]); + $quoteIdMaskMock->method('load')->willReturn($quoteIdMaskMock); + $quoteIdMaskMock->method('getQuoteId') + ->willThrowException(new ExpressInitException(__('Localized test exception!'))); + $this->quoteIdMaskFactoryMock->method('create')->willReturn($quoteIdMaskMock); + + $this->resolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + [], + $args + ); + + $this->loggerMock->expects($this->once())->method('error'); + } +} \ No newline at end of file diff --git a/Test/Unit/Model/Resolver/AdyenExpressInitPaymentsResolverTest.php b/Test/Unit/Model/Resolver/AdyenExpressInitPaymentsResolverTest.php new file mode 100644 index 00000000..9dca1d4a --- /dev/null +++ b/Test/Unit/Model/Resolver/AdyenExpressInitPaymentsResolverTest.php @@ -0,0 +1,129 @@ + + */ + +namespace Adyen\ExpressCheckout\Test\Unit\Model\Resolver; + +use Adyen\ExpressCheckout\Api\AdyenInitPaymentsInterface; +use Adyen\ExpressCheckout\Model\AdyenInitPayments; +use Adyen\ExpressCheckout\Model\Resolver\AdyenExpressInitPaymentsResolver; +use Adyen\Payment\Model\Resolver\DataProvider\GetAdyenPaymentStatus; +use PHPUnit\Framework\MockObject\MockObject; + +class AdyenExpressInitPaymentsResolverTest extends AbstractAdyenResolverTestCase +{ + protected MockObject&AdyenInitPaymentsInterface $adyenInitPaymentsMock; + protected MockObject&GetAdyenPaymentStatus $adyenGetPaymentStatusMock; + + /** + * Build the class under test and the dependencies + * + * @return void + */ + public function setUp(): void + { + // Build generic mocks for Adyen resolver tests + parent::setUp(); + + // Build class specific mocks + $this->adyenInitPaymentsMock = $this->createMock(AdyenInitPayments::class); + $this->adyenGetPaymentStatusMock = $this->createMock(GetAdyenPaymentStatus::class); + + $this->resolver = new AdyenExpressInitPaymentsResolver( + $this->adyenInitPaymentsMock, + $this->valueFactoryMock, + $this->quoteIdMaskFactoryMock, + $this->loggerMock, + $this->adyenGetPaymentStatusMock + ); + } + + /** + * Data provider for abstract test case testResolverShouldThrowExceptionWithEmptyArgument() + * + * @return array[] + */ + protected static function emptyArgumentAssertionDataProvider(): array + { + return [ + [ + 'args' => [ + 'adyenCartId' => 'adyenMaskedQuoteId' + ] + ], + [ + 'args' => [ + 'stateData' => 'mock_state_data' + ] + ], + [ + 'args' => [ + 'stateData' => 'mock_state_data', + 'adyenCartId' => '', + 'adyenMaskedQuoteId' => '' + ] + ], + [ + 'args' => [ + 'stateData' => 'mock_state_data', + 'adyenMaskedQuoteId' => '' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testSuccessfulResolving() + * + * @return array + */ + protected static function successfulResolverDataProvider(): array + { + return [ + [ + 'args' => [ + 'stateData' => 'json_encoded_state_data', + 'adyenMaskedQuoteId' => 'adyenMaskedQuoteId', + 'adyenCartId' => 'adyenCartId' + ] + ], + [ + 'args' => [ + 'stateData' => 'json_encoded_state_data', + 'adyenMaskedQuoteId' => 'adyenMaskedQuoteId' + ] + ], + [ + 'args' => [ + 'stateData' => 'json_encoded_state_data', + 'adyenCartId' => 'adyenCartId' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testMissingQuoteShouldThrowException() + * + * @return array + */ + protected static function missingQuoteAssertionDataProvider(): array + { + return [ + [ + 'args' => [ + 'stateData' => 'adyen_state_data_mock', + 'adyenMaskedQuoteId' => 'mock_product_cart_params', + 'adyenCartId' => 'mock_adyenCartId' + ] + ] + ]; + } +} \ No newline at end of file diff --git a/Test/Unit/Model/Resolver/AdyenExpressPaypalUpdateOrderResolverTest.php b/Test/Unit/Model/Resolver/AdyenExpressPaypalUpdateOrderResolverTest.php new file mode 100644 index 00000000..aa4f8250 --- /dev/null +++ b/Test/Unit/Model/Resolver/AdyenExpressPaypalUpdateOrderResolverTest.php @@ -0,0 +1,130 @@ + + */ + +namespace Adyen\ExpressCheckout\Test\Unit\Model\Resolver; + +use Adyen\ExpressCheckout\Api\AdyenPaypalUpdateOrderInterface; +use Adyen\ExpressCheckout\Helper\PaypalUpdateOrder; +use Adyen\ExpressCheckout\Model\Resolver\AdyenExpressPaypalUpdateOrderResolver; +use PHPUnit\Framework\MockObject\MockObject; + +class AdyenExpressPaypalUpdateOrderResolverTest extends AbstractAdyenResolverTestCase +{ + protected MockObject|AdyenPaypalUpdateOrderInterface $adyenPaypalUpdateOrderMock; + protected MockObject|PaypalUpdateOrder $paypalUpdateOrderHelperMock; + + /** + * Build the class under test and the dependencies + * + * @return void + */ + public function setUp(): void + { + // Build generic mocks for Adyen resolver tests + parent::setUp(); + + // Build class specific mocks + $this->adyenPaypalUpdateOrderMock = $this->createMock(AdyenPaypalUpdateOrderInterface::class); + $this->paypalUpdateOrderHelperMock = $this->createMock(PaypalUpdateOrder::class); + + $this->resolver = new AdyenExpressPaypalUpdateOrderResolver( + $this->adyenPaypalUpdateOrderMock, + $this->valueFactoryMock, + $this->quoteIdMaskFactoryMock, + $this->loggerMock, + $this->paypalUpdateOrderHelperMock + ); + } + + /** + * Data provider for abstract test case testResolverShouldThrowExceptionWithEmptyArgument() + * + * @return array[] + */ + protected static function emptyArgumentAssertionDataProvider(): array + { + return [ + [ + 'args' => [] + ], + [ + 'args' => [ + 'paymentData' => 'mock_payment_data' + ] + ], + [ + 'args' => [ + 'paymentData' => 'mock_payment_data', + 'adyenCartId' => '', + 'adyenMaskedQuoteId' => '' + ] + ], + [ + 'args' => [ + 'paymentData' => 'mock_payment_data', + 'adyenMaskedQuoteId' => '' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testSuccessfulResolving() + * + * @return array + */ + protected static function successfulResolverDataProvider(): array + { + return [ + [ + 'args' => [ + 'paymentData' => 'mock_payment_data', + 'adyenMaskedQuoteId' => 'adyenMaskedQuoteId', + 'adyenCartId' => 'adyenCartId', + 'deliveryMethods' => [ + ['reference' => 1, 'description' => 'Flat rate', 'selected' => true], + ['reference' => 2, 'description' => 'Express delivery', 'selected' => false] + ] + ] + ], + [ + 'args' => [ + 'paymentData' => 'mock_payment_data', + 'adyenMaskedQuoteId' => 'adyenMaskedQuoteId' + ] + ], + [ + 'args' => [ + 'paymentData' => 'mock_payment_data', + 'adyenCartId' => 'adyenCartId' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testMissingQuoteShouldThrowException() + * + * @return array + */ + protected static function missingQuoteAssertionDataProvider(): array + { + return [ + [ + 'args' => [ + 'paymentData' => 'mock_payment_data', + 'adyenMaskedQuoteId' => 'mock_product_cart_params', + 'adyenCartId' => 'mock_adyenCartId' + ] + ] + ]; + } +} \ No newline at end of file diff --git a/Test/Unit/Model/Resolver/ExpressActivateResolverTest.php b/Test/Unit/Model/Resolver/ExpressActivateResolverTest.php new file mode 100644 index 00000000..c6c6c98f --- /dev/null +++ b/Test/Unit/Model/Resolver/ExpressActivateResolverTest.php @@ -0,0 +1,106 @@ + + */ + +namespace Adyen\ExpressCheckout\Test\Unit\Model\Resolver; + +use Adyen\ExpressCheckout\Model\ExpressActivate; +use Adyen\ExpressCheckout\Model\Resolver\ExpressActivateResolver; +use PHPUnit\Framework\MockObject\MockObject; + +class ExpressActivateResolverTest extends AbstractAdyenResolverTestCase +{ + protected MockObject&ExpressActivate $expressActivateMock; + + /** + * Build the class under test and the dependencies + * + * @return void + */ + public function setUp(): void + { + // Build generic mocks for Adyen resolver tests + parent::setUp(); + + // Build class specific mocks + $this->expressActivateMock = $this->createMock(ExpressActivate::class); + + $this->resolver = new ExpressActivateResolver( + $this->expressActivateMock, + $this->valueFactoryMock, + $this->quoteIdMaskFactoryMock, + $this->loggerMock + ); + } + + /** + * Data provider for abstract test case testSuccessfulResolving() + * + * @return array + */ + public static function successfulResolverDataProvider(): array + { + return [ + [ + 'args' => [ + 'adyenMaskedQuoteId' => 'mock_adyen_masked_quote_id', + 'adyenCartId' => 'mock_adyen_cart_id' + ] + ], + [ + 'args' => [ + 'adyenMaskedQuoteId' => 'mock_adyen_masked_quote_id', + 'adyenCartId' => null + ] + ], + [ + 'args' => [ + 'adyenMaskedQuoteId' => 'mock_adyen_masked_quote_id' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testResolverShouldThrowExceptionWithEmptyArgument() + * + * @return array[] + */ + protected static function emptyArgumentAssertionDataProvider(): array + { + return [ + [ + 'args' => [] + ], + [ + 'args' => [ + 'adyenMaskedQuoteId' => '' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testMissingQuoteShouldThrowException() + * + * @return array + */ + protected static function missingQuoteAssertionDataProvider(): array + { + return [ + [ + 'args' => [ + 'adyenMaskedQuoteId' => 'mock_product_cart_params', + 'adyenCartId' => 'Mock_adyenCartId' + ] + ] + ]; + } +} diff --git a/Test/Unit/Model/Resolver/ExpressCancelResolverTest.php b/Test/Unit/Model/Resolver/ExpressCancelResolverTest.php new file mode 100644 index 00000000..3f6f8fb6 --- /dev/null +++ b/Test/Unit/Model/Resolver/ExpressCancelResolverTest.php @@ -0,0 +1,93 @@ + + */ + +namespace Adyen\ExpressCheckout\Test\Unit\Model\Resolver; + +use Adyen\ExpressCheckout\Model\ExpressCancel; +use Adyen\ExpressCheckout\Model\Resolver\ExpressCancelResolver; +use PHPUnit\Framework\MockObject\MockObject; + +class ExpressCancelResolverTest extends AbstractAdyenResolverTestCase +{ + protected MockObject&ExpressCancel $expressCancelMock; + + /** + * Build the class under test and the dependencies + * + * @return void + */ + public function setUp(): void + { + // Build generic mocks for Adyen resolver tests + parent::setUp(); + + // Build class specific mocks + $this->expressCancelMock = $this->createMock(ExpressCancel::class); + + $this->resolver = new ExpressCancelResolver( + $this->expressCancelMock, + $this->valueFactoryMock, + $this->quoteIdMaskFactoryMock, + $this->loggerMock + ); + } + + /** + * Data provider for abstract test case testSuccessfulResolving() + * + * @return array + */ + public static function successfulResolverDataProvider(): array + { + return [ + [ + 'args' => [ + 'adyenMaskedQuoteId' => 'mock_adyen_masked_quote_id' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testResolverShouldThrowExceptionWithEmptyArgument() + * + * @return array[] + */ + protected static function emptyArgumentAssertionDataProvider(): array + { + return [ + [ + 'args' => [] + ], + [ + 'args' => [ + 'adyenMaskedQuoteId' => '' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testMissingQuoteShouldThrowException() + * + * @return array + */ + protected static function missingQuoteAssertionDataProvider(): array + { + return [ + [ + 'args' => [ + 'adyenMaskedQuoteId' => 'mock_adyen_masked_quote_id' + ] + ] + ]; + } +} diff --git a/Test/Unit/Model/Resolver/ExpressInitResolverTest.php b/Test/Unit/Model/Resolver/ExpressInitResolverTest.php new file mode 100644 index 00000000..270f0452 --- /dev/null +++ b/Test/Unit/Model/Resolver/ExpressInitResolverTest.php @@ -0,0 +1,202 @@ + + */ + +namespace Adyen\ExpressCheckout\Test\Unit\Model\Resolver; + +use Adyen\ExpressCheckout\Api\Data\ProductCartParamsInterfaceFactory; +use Adyen\ExpressCheckout\Model\ExpressInit; +use Adyen\ExpressCheckout\Model\ProductCartParams; +use Adyen\ExpressCheckout\Model\Resolver\ExpressInitResolver; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use PHPUnit\Framework\MockObject\MockObject; + +class ExpressInitResolverTest extends AbstractAdyenResolverTestCase +{ + const SUCCESSFUL_CART_PARAMS = "{\"product\":1562,\"qty\":\"5\",\"super_attribute\":{\"93\":56,\"144\":166}}"; + + protected MockObject&ExpressInit $expressInitMock; + protected MockObject&ProductCartParamsInterfaceFactory $cartParamsInterfaceFactoryMock; + + /** + * Build the class under test and the dependencies + * + * @return void + */ + public function setUp(): void + { + // Build generic mocks for Adyen resolver tests + parent::setUp(); + + // Build class specific mocks + $this->expressInitMock = $this->createMock(ExpressInit::class); + $this->cartParamsInterfaceFactoryMock = $this->createGeneratedMock( + ProductCartParamsInterfaceFactory::class, + ['create'] + ); + + $this->resolver = new ExpressInitResolver( + $this->expressInitMock, + $this->cartParamsInterfaceFactoryMock, + $this->valueFactoryMock, + $this->quoteIdMaskFactoryMock, + $this->loggerMock + ); + } + + /** + * See the data provider for test case explanations. + * + * @dataProvider invalidProductCartParamsDataProvider + * + * @return void + * @throws GraphQlInputException|LocalizedException|Exception + */ + public function testProductCartParamsShouldThrowException($productCartParams) + { + $this->expectException(GraphQlInputException::class); + + $args = [ + 'productCartParams' => $productCartParams + ]; + + $this->resolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + [], + $args + ); + } + + /** + * This test case extends the abstract testSuccessfulResolving() test case + * + * @dataProvider successfulResolverDataProvider + * + * @param $args + * @return void + * @throws Exception + */ + public function testSuccessfulResolving($args) + { + $productCartParamsMock = $this->createMock(ProductCartParams::class); + $this->cartParamsInterfaceFactoryMock->method('create')->willReturn($productCartParamsMock); + + parent::testSuccessfulResolving($args); + } + + /** + * This test case extends the abstract testMissingQuoteShouldThrowException() test case + * + * @dataProvider missingQuoteAssertionDataProvider + * + * @param $args + * @return void + * @throws Exception + */ + public function testMissingQuoteShouldThrowException($args) + { + $productCartParamsMock = $this->createMock(ProductCartParams::class); + $this->cartParamsInterfaceFactoryMock->method('create')->willReturn($productCartParamsMock); + + parent::testSuccessfulResolving($args); + } + + /** + * Data provider for case testProductCartParamsShouldThrowException + * + * @return array[] + */ + protected static function invalidProductCartParamsDataProvider(): array + { + return [ + // Simulate empty string for input field `productCartParams` and expect `GraphQlInputException` + ['productCartParams' => ''], + // Simulate invalid JSON object for input field `productCartParams` and expect `GraphQlInputException` + ['productCartParams' => '{\"product\":1562,\"qty\":\"5\",\"super_att.._invalidJSON'] + ]; + } + + /** + * Data provider for abstract test case testSuccessfulResolving() + * + * @return array + */ + public static function successfulResolverDataProvider(): array + { + return [ + [ + 'args' => [ + 'productCartParams' => self::SUCCESSFUL_CART_PARAMS, + 'adyenCartId' => 'Mock_adyenCartId', + 'adyenMaskedQuoteId' => 'Mock_adyenMaskedQuoteId' + ] + ], + [ + 'args' => [ + 'productCartParams' => self::SUCCESSFUL_CART_PARAMS, + 'adyenCartId' => null, + 'adyenMaskedQuoteId' => null + ] + ], + [ + 'args' => [ + 'productCartParams' => self::SUCCESSFUL_CART_PARAMS, + 'adyenCartId' => "", + 'adyenMaskedQuoteId' => "" + ] + ], + [ + 'args' => [ + 'productCartParams' => self::SUCCESSFUL_CART_PARAMS + ] + ] + ]; + } + + /** + * Data provider for abstract test case testResolverShouldThrowExceptionWithEmptyArgument() + * + * @return array[] + */ + protected static function emptyArgumentAssertionDataProvider(): array + { + return [ + [ + 'args' => [] + ], + [ + 'args' => [ + 'productCartParams' => '' + ] + ] + ]; + } + + /** + * Data provider for abstract test case testMissingQuoteShouldThrowException() + * + * @return array + */ + protected static function missingQuoteAssertionDataProvider(): array + { + return [ + [ + 'args' => [ + 'productCartParams' => self::SUCCESSFUL_CART_PARAMS, + 'adyenCartId' => 'Mock_adyenCartId' + ] + ] + ]; + } +} \ No newline at end of file diff --git a/Test/Unit/Observer/UpdatePaypalOrderStatusTest.php b/Test/Unit/Observer/UpdatePaypalOrderStatusTest.php new file mode 100644 index 00000000..2adde3d7 --- /dev/null +++ b/Test/Unit/Observer/UpdatePaypalOrderStatusTest.php @@ -0,0 +1,119 @@ + + */ + +namespace Adyen\ExpressCheckout\Test\Unit\Model\Resolver; + +use Adyen\ExpressCheckout\Block\Paypal\Shortcut\Button; +use Adyen\ExpressCheckout\Observer\UpdatePaypalOrderStatus; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Magento\Framework\Event; +use Magento\Framework\Event\Observer; +use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Config; +use PHPUnit\Framework\MockObject\MockObject; + +class UpdatePaypalOrderStatusTest extends AbstractAdyenTestCase +{ + protected ?UpdatePaypalOrderStatus $updatePaypalOrderStatusObserver; + protected MockObject|Observer $observerMock; + protected MockObject|Order $orderMock; + protected MockObject|PaymentInterface $paymentMock; + + /** + * @return void + */ + public function setUp(): void + { + $this->paymentMock = $this->createMock(PaymentInterface::class); + + // Setting the value and the methods of the mock globally since this is not variable among test cases. + $orderConfigMock = $this->createMock(Config::class); + $orderConfigMock->method('getStateDefaultStatus') + ->with(Order::STATE_NEW) + ->willReturn('pending'); + + $this->orderMock = $this->createGeneratedMock(Order::class, + ['save', 'getPayment', 'setState', 'getConfig'] + ); + $this->orderMock->method('getPayment')->willReturn($this->paymentMock); + $this->orderMock->method('getConfig')->willReturn($orderConfigMock); + + $eventMock = $this->createGeneratedMock(Event::class, ['getOrder']); + $eventMock->method('getOrder')->willReturn($this->orderMock); + + $this->observerMock = $this->createMock(Observer::class); + $this->observerMock->method('getEvent')->willReturn($eventMock); + + $this->updatePaypalOrderStatusObserver = new UpdatePaypalOrderStatus(); + } + + /** + * @return void + */ + public function tearDown(): void + { + $this->updatePaypalOrderStatusObserver = null; + } + + /** + * Test case for triggering the observer for Adyen PayPal express payments + * + * @return void + */ + public function testUpdatePaypalOrderStatusAdyenExpressOrders() + { + $this->paymentMock->method('getMethod')->willReturn(Button::PAYPAL_EXPRESS_METHOD_NAME); + + $this->paymentMock->expects($this->once()) + ->method('setMethod') + ->with(Button::PAYPAL_METHOD_NAME); + + $this->orderMock->expects($this->once()) + ->method('setState') + ->with(Order::STATE_NEW); + + $this->orderMock->expects($this->once())->method('save'); + + $this->updatePaypalOrderStatusObserver->execute($this->observerMock); + } + + /** + * Test case for skipping the observer for non-Adyen PayPal express payments + * + * @dataProvider nonAdyenPayPalExpressMethodProvider + * @return void + */ + public function testUpdatePaypalOrderStatusNonAdyenExpressOrders($paymentMethodName) + { + $this->paymentMock->method('getMethod')->willReturn($paymentMethodName); + + $this->paymentMock->expects($this->never())->method('setMethod'); + $this->orderMock->expects($this->never())->method('setState'); + $this->orderMock->expects($this->never())->method('save'); + + $this->updatePaypalOrderStatusObserver->execute($this->observerMock); + } + + /** + * Data provider for test case `testUpdatePaypalOrderStatusNonAdyenExpressOrders()` + * + * @return array[] + */ + private static function nonAdyenPayPalExpressMethodProvider(): array + { + return [ + ['paymentMethodName' => 'irrelevant_payment_method'], + ['paymentMethodName' => 'adyen_ideal'], + ['paymentMethodName' => 'adyen_paypal'] + ]; + } +} diff --git a/Test/api-functional/GraphQl/AdyenExpressTest.php b/Test/api-functional/GraphQl/AdyenExpressTest.php new file mode 100644 index 00000000..63d566fa --- /dev/null +++ b/Test/api-functional/GraphQl/AdyenExpressTest.php @@ -0,0 +1,300 @@ + + */ + +namespace Adyen\Payment\GraphQl; + +use Exception; +use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQl\ResponseContainsErrorsException; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +class AdyenExpressTest extends GraphQlAbstract +{ + protected ?GetMaskedQuoteIdByReservedOrderId $getMaskedQuoteIdByReservedOrderId; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $objectManager = Bootstrap::getObjectManager(); + $this->getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + } + + /** + * @return array + * @throws Exception + */ + protected function initializeExpressQuoteOnPdp(): array + { + // Generate express quote for PDP + $query = <<graphQlMutation($query); + } + + /** + * Test case for expressInit mutation should throw an exception + * with an empty `productCartParams` input field. + * + * @return void + * @throws Exception + */ + public function testEmptyCartParamsShouldThrowException(): void + { + $query = <<expectException(ResponseContainsErrorsException::class); + $this->graphQlMutation($query); + } + + /** + * Test case for expressInit mutation with valid productCartParams + * should generate a valid quote and return `masked_quote_id`. + * + * @return void + * @throws Exception + */ + public function testSuccessfulQuoteInitiation(): void + { + $response = $this->initializeExpressQuoteOnPdp(); + + $this->assertArrayHasKey('expressInit', $response); + $this->assertArrayHasKey('masked_quote_id', $response['expressInit']); + } + + /** + * Test case for expressInit mutation with valid productCartParams + * should update the quote and return the updated quote with the same masked_quote_id. + * + * @return void + * @throws Exception + */ + public function testSuccessfulQuoteUpdate(): void + { + // Initialize express quote to perform the test + $initialResponse = $this->initializeExpressQuoteOnPdp(); + $maskedQuoteId = $initialResponse['expressInit']['masked_quote_id']; + + $query = <<graphQlMutation($query); + + $this->assertArrayHasKey('expressInit', $updateCallResponse); + $this->assertArrayHasKey('totals', $updateCallResponse['expressInit']); + $this->assertArrayHasKey('items_qty', $updateCallResponse['expressInit']['totals']); + $this->assertEquals('5', $updateCallResponse['expressInit']['totals']['items_qty']); + $this->assertArrayHasKey('masked_quote_id', $updateCallResponse['expressInit']); + $this->assertEquals($maskedQuoteId, $updateCallResponse['expressInit']['masked_quote_id']); + } + + /** + * Test case for expressActivate mutation with valid adyenMaskedQuoteId + * should activate the quote and return TRUE. + * + * @return void + * @throws Exception + */ + public function testSuccessfulExpressQuoteActivation(): void + { + // Initialize express quote to perform the test + $initiateQuote = $this->initializeExpressQuoteOnPdp(); + $maskedQuoteId = $initiateQuote['expressInit']['masked_quote_id']; + + $query = <<graphQlMutation($query); + + $this->assertArrayHasKey('expressActivate', $response); + $this->assertTrue($response['expressActivate']); + } + + /** + * Test case for expressCancel mutation with valid adyenCartId + * should activate the quote and return TRUE. + * + * @return void + * @throws Exception + */ + public function testSuccessfulExpressQuoteCancellation(): void + { + // Initialize express quote to perform the test + $initiateQuote = $this->initializeExpressQuoteOnPdp(); + $expressMaskedQuoteId = $initiateQuote['expressInit']['masked_quote_id']; + + $query = <<graphQlMutation($query); + + $this->assertArrayHasKey('expressCancel', $response); + $this->assertTrue($response['expressCancel']); + } + + /** + * Test case for adyenExpressInitPayments mutation with valid cartId + * should return `/payments` response with `action` and `resultCode`. + * + * @return void + * @throws Exception + */ + public function testSuccessfulPaymentsCallInitiation(): void + { + // Initialize express quote to perform the test + $initiateQuoteResponse = $this->initializeExpressQuoteOnPdp(); + $expressMaskedQuoteId = $initiateQuoteResponse['expressInit']['masked_quote_id']; + + // Start the test case + $query = <<graphQlMutation($query); + + $this->assertArrayHasKey('adyenExpressInitPayments', $response); + $this->assertArrayHasKey('isFinal', $response['adyenExpressInitPayments']); + $this->assertArrayHasKey('resultCode', $response['adyenExpressInitPayments']); + $this->assertArrayHasKey('action', $response['adyenExpressInitPayments']); + $this->assertEquals('Pending', $response['adyenExpressInitPayments']['resultCode']); + $this->assertFalse($response['adyenExpressInitPayments']['isFinal']); + } + + /** + * Test case for adyenExpressPaypalUpdateOrder mutation with valid paymentData + * should return `status: success` with the updated `paymentData`. + * + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/create_empty_cart.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/guest/set_guest_email.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_shipping_address.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_new_billing_address.php + * @magentoApiDataFixture Magento/GraphQl/Quote/_files/set_flatrate_shipping_method.php + */ + public function testExpressPaypalUpdateOrder(): void + { + // Fetch the quote ID from the data fixture + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); + + // Make /payments call to obtain initial `paymentData` + $query = <<graphQlMutation($query); + $this->assertArrayHasKey('action', $expressInitResponse['adyenExpressInitPayments']); + + $action = json_decode($expressInitResponse['adyenExpressInitPayments']['action'], true); + $this->assertArrayHasKey('paymentData', $action); + + // Start the test case and update the PayPal order with the new amount including shipping cost + $query = <<<'MUTATION' + mutation AdyenExpressPaypalUpdateOrder( + $paymentData: String!, + $deliveryMethods: [PaypalDeliveryMethodInput], + $adyenCartId: String + ) { + adyenExpressPaypalUpdateOrder( + paymentData: $paymentData, + adyenCartId: $adyenCartId, + deliveryMethods: $deliveryMethods + ) { + status + paymentData + } + } + MUTATION; + + $variables = [ + 'paymentData' => $action['paymentData'], + 'deliveryMethods' => [ + [ + 'reference' => '1', + 'description' => 'Flat rate', + 'type' => 'Shipping', + 'amount' => [ + 'currency' => 'EUR', + 'value' => 1000, + ], + 'selected' => true + ] + ], + 'adyenCartId' => $maskedQuoteId, + ]; + + $adyenExpressPaypalUpdateOrderResponse = $this->graphQlMutation($query, $variables); + + $this->assertArrayHasKey( + 'status', + $adyenExpressPaypalUpdateOrderResponse['adyenExpressPaypalUpdateOrder'] + ); + $this->assertEquals( + 'success', + $adyenExpressPaypalUpdateOrderResponse['adyenExpressPaypalUpdateOrder']['status'] + ); + } +} diff --git a/Test/bootstrap.php b/Test/bootstrap.php new file mode 100644 index 00000000..04e107f3 --- /dev/null +++ b/Test/bootstrap.php @@ -0,0 +1,13 @@ + + */ + +require_once __DIR__.'/../vendor/autoload.php'; diff --git a/Test/phpunit.xml b/Test/phpunit.xml new file mode 100644 index 00000000..8f58cea2 --- /dev/null +++ b/Test/phpunit.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + . + + + + + + ../. + + + ../vendor + . + + + diff --git a/Test/phpunit_graphql.php b/Test/phpunit_graphql.php new file mode 100644 index 00000000..e7a51243 --- /dev/null +++ b/Test/phpunit_graphql.php @@ -0,0 +1,7 @@ + - + diff --git a/etc/payment.xml b/etc/payment.xml new file mode 100644 index 00000000..192b24d4 --- /dev/null +++ b/etc/payment.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/etc/schema.graphqls b/etc/schema.graphqls new file mode 100644 index 00000000..f2ca4812 --- /dev/null +++ b/etc/schema.graphqls @@ -0,0 +1,157 @@ +type Mutation { + expressInit ( + productCartParams: String! @doc(description: "JSON encoded product cart parameters (See `ProductCartParamsInterface`)") + adyenMaskedQuoteId: String @doc(description: "Adyen express quote ID for PDP") + adyenCartId: String @doc(description: "Original quote ID of the cart") + ): ExpressData @resolver(class: "Adyen\\ExpressCheckout\\Model\\Resolver\\ExpressInitResolver") + + expressActivate ( + adyenMaskedQuoteId: String! @doc(description: "Adyen express quote ID for PDP") + adyenCartId: String @doc(description: "Original quote ID of the cart") + ): Boolean @resolver(class: "Adyen\\ExpressCheckout\\Model\\Resolver\\ExpressActivateResolver") + + expressCancel ( + adyenMaskedQuoteId: String! @doc(description: "Adyen express quote ID for PDP") + ): Boolean @resolver(class: "Adyen\\ExpressCheckout\\Model\\Resolver\\ExpressCancelResolver") + + adyenExpressInitPayments ( + stateData: String! @doc(description: "JSON encoded `data` object obtained from the payment component") + adyenCartId: String @doc(description: "Original quote ID of the cart") + adyenMaskedQuoteId: String @doc(description: "Adyen express quote ID for PDP") + ): AdyenPaymentStatus @resolver(class: "Adyen\\ExpressCheckout\\Model\\Resolver\\AdyenExpressInitPaymentsResolver") + + adyenExpressPaypalUpdateOrder ( + paymentData: String! @doc(description: "`paymentData` field from the `/payments` response or previous `/paypal/updateOrder` response") + deliveryMethods: [PaypalDeliveryMethodInput] @doc(description: "Delivery methods to be shown on the PayPal pop-up") + adyenCartId: String @doc(description: "Original quote ID of the cart") + adyenMaskedQuoteId: String @doc(description: "Adyen express quote ID for PDP") + ): AdyenExpressPaypalUpdateOrderStatus @resolver(class: "Adyen\\ExpressCheckout\\Model\\Resolver\\AdyenExpressPaypalUpdateOrderResolver") +} + +type ExpressData { + masked_quote_id: String @doc(description: "Adyen express quote ID for PDP") + is_virtual_quote: Boolean @doc(description: "Shows whether if the quote is virtual or not") + adyen_payment_methods: AdyenPaymentMethods @doc(description: "Lists Adyen payment methods with extra details") + totals: Totals @doc(description: "Cart totals") +} + +type AdyenPaymentMethods { + extra_details: [ExtraDetail] @doc(description: "Provides icon, method name, configuration and shows whether if it is an open invoice payment method or not") + methods_response: [MethodResponse] @doc(description: "Provides configuration from /paymentMethods call as well as method name and supported card brands") +} + +type ExtraDetail { + icon: Icon @doc(description: "Payment method icon") + configuration: ExtraDetailConfiguration @doc(description: "Configuration object contaning amount and currency") + method: String @doc(description: "Payment method name") + is_open_invoice: Boolean @doc(description: "Shows whether if it is open invoice or not") +} + +type ExtraDetailConfiguration { + amount: Amount @doc(description: "Amount and currency") + currency: String @doc(description: "Currency") +} + +type Amount { + value: Int @doc(description: "Amount value in minor units") + currency: String @doc(description: "Currency") +} + +type Icon { + url: String @doc(description: "Payment method icon URL") + width: Int @doc(description: "Icon width in pixels") + height: Int @doc(description: "Icon height in pixels") +} + +type MethodResponse { + configuration: MethodResponseConfiguration @doc(description: "Configuration object returning from /paymentMethods call") + name: String @doc(description: "Payment method title") + brands: [String] @doc(description: "Supported card networks") + type: String @doc(description: "Payment method tx_variant") +} + +type MethodResponseConfiguration { + merchant_id: String @doc(description: "Merchant ID from Customer Area") + gateway_merchant_id: String @doc(description: "Gateway Merchant ID from Customer Area") + intent: String @doc(description: "Intent of payment") + merchant_name: String @doc(description: "Merchant account name") +} + +type Totals { + grand_total: Float @doc(description: "Grand total in quote currency") + base_grand_total: Float @doc(description: "Grand total in base currency") + subtotal: Float @doc(description: "Subtotal in quote currency") + base_subtotal: Float @doc(description: "Subtotal in quote currency") + discount_amount: Float @doc(description: "Discount amount in quote currency") + base_discount_amount: Float @doc(description: "Discount amount in base currency") + subtotal_with_discount: Float @doc(description: "Subtotal in quote currency with applied discount") + base_subtotal_with_discount: Float @doc(description: "Subtotal in base currency with applied discount") + shipping_amount: Float @doc(description: "Shipping amount in quote currency") + base_shipping_amount: Float @doc(description: "Shipping amount in base currency") + shipping_discount_amount: Float @doc(description: "Shipping discount amount in quote currency") + base_shipping_discount_amount: Float @doc(description: "Shipping discount amount in base currency") + tax_amount: Float @doc(description: "Tax amount in quote currency") + base_tax_amount: Float @doc(description: "Tax amount in base currency") + weee_tax_applied_amount: Float @doc(description: "The total weee tax applied amount in quote currency") + shipping_tax_amount: Float @doc(description: "Shipping tax amount in quote currency") + base_shipping_tax_amount: Float @doc(description: "Shipping tax amount in base currency") + subtotal_incl_tax: Float @doc(description: "Subtotal including tax in quote currency") + base_subtotal_incl_tax: Float @doc(description: "Subtotal including tax in base currency") + shipping_incl_tax: Float @doc(description: "Shipping including tax in quote currency") + base_shipping_incl_tax: Float @doc(description: "Shipping including tax in base currency") + base_currency_code: String @doc(description: "Base currency code") + quote_currency_code: String @doc(description: "Quote currency code") + coupon_code: String @doc(description: "Applied coupon code") + items: [TotalsItem] @doc(description: "Totals by items") + total_segments: [TotalSegment] @doc(description: "Dynamically calculated totals") + items_qty: Int @doc(description: "Items qty") +} + +type TotalsItem { + item_id: Int @doc(description: "Item id") + price: Float @doc(description: "Price") + base_price: Float @doc(description: "Base price") + qty: Float @doc(description: "Quantity") + row_total: Float @doc(description: "Row total") + base_row_total: Float @doc(description: "Base row total") + row_total_with_discount: Float @doc(description: "Row total with discount") + discount_amount: Float @doc(description: "Discount amount") + base_discount_amount: Float @doc(description: "Base discount amount") + discount_percent: Float @doc(description: "Discount percent") + tax_amount: Float @doc(description: "Tax amount") + base_tax_amount: Float @doc(description: "Base tax amount") + tax_percent: Float @doc(description: "Tax percent") + price_incl_tax: Float @doc(description: "Price including tax") + base_price_incl_tax: Float @doc(description: "Base price including tax") + row_total_incl_tax: Float @doc(description: "Row total including tax") + base_row_total_incl_tax: Float @doc(description: "Base row total including tax") + options: String @doc(description: "Item options data") + weee_tax_applied_amount: Float @doc(description: "Item Weee Tax Applied Amount") + weee_tax_applied: String @doc(description: "Item Weee Tax Applied Amount") + name: String @doc(description: "Item name") +} + +type TotalSegment { + code: String @doc(description: "Total code") + title: String @doc(description: "Total title") + value: Float @doc(description: "Total value") + area: String @doc(description: "Display area code") +} + +type AdyenExpressPaypalUpdateOrderStatus { + status: String @doc(description: "") + paymentData: String @doc(description: "") +} + +input PaypalDeliveryMethodInput { + reference: String @doc(description: "Unique reference number of the delivery method") + description: String @doc(description: "The description shown on the pop-up") + type: String @doc(description: "Order delivery type") + amount: AmountInput @doc(description: "Amount and currency of the row totals (without tax and shipping amounts)") + selected: Boolean @doc(description: "Default delivery method") +} + +input AmountInput { + value: Int @doc(description: "Amount value in minor units") + currency: String @doc(description: "Currency") +} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..8a36b355 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,6 @@ +sonar.organization=adyen +sonar.projectKey=Adyen_adyen-magento2-express-checkout +sonar.sources=. +sonar.exclusions=vendor/**/*, Test/**/*, view/**/* +sonar.php.coverage.reportPaths=build/clover.xml +sonar.php.tests.reportPath=build/tests-log.xml diff --git a/view/frontend/web/js/applepay/button.js b/view/frontend/web/js/applepay/button.js index 22ef658d..ce49183e 100644 --- a/view/frontend/web/js/applepay/button.js +++ b/view/frontend/web/js/applepay/button.js @@ -364,7 +364,7 @@ define([ setShippingInformation(shippingInformationPayload, self.isProductView).then(() => { setTotalsInfo(totalsPayload, self.isProductView) .done((response) => { - self.afterSetTotalsInfo(response, shippingMethods[0], self.isProductView, resolve); + self.afterSetTotalsInfo(response, shippingMethods, self.isProductView, resolve); }) .fail((e) => { console.error('Adyen ApplePay: Unable to get totals', e); @@ -420,6 +420,13 @@ define([ label: this.getMerchantName(), amount: (response.grand_total).toString() }; + + // If the shipping methods is an array pass all methods to Apple Pay to show in payment window. + if (Array.isArray(shippingMethod)) { + applePayShippingMethodUpdate.newShippingMethods = shippingMethod; + shippingMethod = shippingMethod[0]; + } + applePayShippingMethodUpdate.newLineItems = [ { type: 'final', diff --git a/view/frontend/web/js/googlepay/button.js b/view/frontend/web/js/googlepay/button.js index a6eeb6ea..248d0983 100644 --- a/view/frontend/web/js/googlepay/button.js +++ b/view/frontend/web/js/googlepay/button.js @@ -8,7 +8,7 @@ define([ 'Magento_Checkout/js/model/quote', 'Adyen_Payment/js/adyen', 'Adyen_Payment/js/model/adyen-payment-modal', - 'Adyen_Payment/js/model/adyen-payment-service', + 'Adyen_ExpressCheckout/js/model/adyen-payment-service', 'Adyen_ExpressCheckout/js/actions/activateCart', 'Adyen_ExpressCheckout/js/actions/cancelCart', 'Adyen_ExpressCheckout/js/actions/createPayment', @@ -261,7 +261,7 @@ define([ currency = paymentMethodExtraDetails.configuration.amount.currency; } - return { + let configuration = { showPayButton: true, countryCode: config.countryCode, environment: config.checkoutenv.toUpperCase(), @@ -278,7 +278,6 @@ define([ phoneNumberRequired: true }, isExpress: true, - callbackIntents: !isVirtual ? ['SHIPPING_ADDRESS', 'SHIPPING_OPTION'] : ['OFFER'], transactionInfo: { totalPriceStatus: 'ESTIMATED', totalPrice: this.isProductView @@ -286,9 +285,6 @@ define([ : formatAmount(getCartSubtotal()), currencyCode: currency }, - paymentDataCallbacks: { - onPaymentDataChanged: this.onPaymentDataChanged.bind(this) - }, allowedPaymentMethods: ['CARD'], phoneNumberRequired: true, configuration: { @@ -302,6 +298,15 @@ define([ onError: () => cancelCart(this.isProductView), ...googlePayStyles }; + + if (!isVirtual) { + configuration.callbackIntents = ['SHIPPING_ADDRESS', 'SHIPPING_OPTION']; + configuration.paymentDataCallbacks = { + onPaymentDataChanged: this.onPaymentDataChanged.bind(this) + }; + } + + return configuration; }, onPaymentDataChanged: function (data) {