diff --git a/Helper/PaymentResponseHandler.php b/Helper/PaymentResponseHandler.php index 5ef5053f19..0f17322086 100644 --- a/Helper/PaymentResponseHandler.php +++ b/Helper/PaymentResponseHandler.php @@ -74,10 +74,16 @@ public function __construct( public function formatPaymentResponse( string $resultCode, - ?array $action = null + ?array $action = null, + ?bool $donationTokenExists = false ): array { switch ($resultCode) { case self::AUTHORISED: + return [ + "isFinal" => true, + "resultCode" => $resultCode, + "canDonate" => $donationTokenExists + ]; case self::REFUSED: case self::ERROR: case self::POS_SUCCESS: diff --git a/Model/Api/AdyenOrderPaymentStatus.php b/Model/Api/AdyenOrderPaymentStatus.php index 55cc124b4a..e12d57010d 100644 --- a/Model/Api/AdyenOrderPaymentStatus.php +++ b/Model/Api/AdyenOrderPaymentStatus.php @@ -61,7 +61,8 @@ public function getOrderPaymentStatus(string $orderId): string return json_encode($this->paymentResponseHandler->formatPaymentResponse( $additionalInformation['resultCode'], - !empty($additionalInformation['action']) ? $additionalInformation['action'] : null + !empty($additionalInformation['action']) ? $additionalInformation['action'] : null, + !empty($additionalInformation['donationToken']) )); } } diff --git a/Model/Api/AdyenPaymentsDetails.php b/Model/Api/AdyenPaymentsDetails.php index 5691a359a6..2f2996539c 100644 --- a/Model/Api/AdyenPaymentsDetails.php +++ b/Model/Api/AdyenPaymentsDetails.php @@ -75,7 +75,7 @@ public function initiate(string $payload, string $orderId): string $this->paymentResponseHandler->formatPaymentResponse( $response['resultCode'], $response['action'] ?? null, - $response['additionalData'] ?? null + !empty($response['donationToken']) ) ); } diff --git a/Model/Api/GuestAdyenOrderPaymentStatus.php b/Model/Api/GuestAdyenOrderPaymentStatus.php index f652881dd2..477486714d 100644 --- a/Model/Api/GuestAdyenOrderPaymentStatus.php +++ b/Model/Api/GuestAdyenOrderPaymentStatus.php @@ -78,7 +78,7 @@ public function getOrderPaymentStatus(string $orderId, string $cartId): string return json_encode($this->paymentResponseHandler->formatPaymentResponse( $additionalInformation['resultCode'], !empty($additionalInformation['action']) ? $additionalInformation['action'] : null, - !empty($additionalInformation['additionalData']) ? $additionalInformation['additionalData'] : null + !empty($additionalInformation['donationToken']) )); } } diff --git a/Model/GraphqlInputArgumentValidator.php b/Model/GraphqlInputArgumentValidator.php new file mode 100644 index 0000000000..ec2cabe0b4 --- /dev/null +++ b/Model/GraphqlInputArgumentValidator.php @@ -0,0 +1,53 @@ + + */ +declare(strict_types=1); + +namespace Adyen\Payment\Model; + +use Magento\Framework\GraphQl\Exception\GraphQlInputException; + +class GraphqlInputArgumentValidator +{ + /** + * Validates GraphQl input arguments + * + * Multidimensional arrays can be validated with fields separated with a dot. + * + * @param array|null $args + * @param array $requiredFields + * @return void + * @throws GraphQlInputException + */ + public function execute(?array $args, array $requiredFields): void + { + $missingFields = []; + + foreach ($requiredFields as $field) { + $keys = explode('.', $field); + $value = $args; + + foreach ($keys as $key) { + $value = $value[$key] ?? null; + } + + if (empty($value)) { + $missingFields[] = $field; + } + } + + if (!empty($missingFields)) { + throw new GraphQlInputException( + __('Required parameters "%1" are missing', implode(', ', $missingFields)) + ); + } + } +} diff --git a/Model/Resolver/AbstractDonationResolver.php b/Model/Resolver/AbstractDonationResolver.php new file mode 100644 index 0000000000..1fe1a26952 --- /dev/null +++ b/Model/Resolver/AbstractDonationResolver.php @@ -0,0 +1,131 @@ + + */ +declare(strict_types=1); + +namespace Adyen\Payment\Model\Resolver; + +use Adyen\Payment\Exception\GraphQlAdyenException; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Model\GraphqlInputArgumentValidator; +use Adyen\Payment\Model\Sales\OrderRepository; +use Exception; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\GraphQl\Helper\Error\AggregateExceptionMessageFormatter; +use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface; +use Magento\Sales\Api\Data\OrderInterface; + +abstract class AbstractDonationResolver implements ResolverInterface +{ + /** + * @param MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId + * @param OrderRepository $orderRepository + * @param GraphqlInputArgumentValidator $graphqlInputArgumentValidator + * @param AdyenLogger $adyenLogger + * @param AggregateExceptionMessageFormatter $adyenGraphQlExceptionMessageFormatter + */ + public function __construct( + protected readonly MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId, + protected readonly OrderRepository $orderRepository, + protected readonly GraphqlInputArgumentValidator $graphqlInputArgumentValidator, + protected readonly AdyenLogger $adyenLogger, + protected readonly AggregateExceptionMessageFormatter $adyenGraphQlExceptionMessageFormatter + ) { } + + /** + * @return array + */ + abstract protected function getRequiredFields(): array; + + /** + * @param OrderInterface $order + * @param array $args + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @return array + * @throws GraphQlAdyenException + */ + abstract protected function performOperation( + OrderInterface $order, + array $args, + Field $field, + $context, + ResolveInfo $info + ): array; + + /** + * @return string + */ + protected function getGenericErrorMessage(): string + { + return 'An error occurred while processing the donation.'; + } + + /** + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @param array|null $value + * @param array|null $args + * @return array + * @throws GraphQlAdyenException + * @throws GraphQlInputException + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + ?array $value = null, + ?array $args = null + ): array { + $this->graphqlInputArgumentValidator->execute($args, $this->getRequiredFields()); + + try { + $quoteId = $this->maskedQuoteIdToQuoteId->execute($args['cartId']); + } catch (NoSuchEntityException $e) { + $this->adyenLogger->error(sprintf("Quote with masked ID %s not found!", $args['cartId'])); + throw new GraphQlAdyenException(__($this->getGenericErrorMessage())); + } + + $order = $this->orderRepository->getOrderByQuoteId($quoteId); + + if (!$order) { + $this->adyenLogger->error(sprintf("Order for quote ID %s not found!", $quoteId)); + throw new GraphQlAdyenException(__($this->getGenericErrorMessage())); + } + + try { + return $this->performOperation($order, $args, $field, $context, $info); + } catch (LocalizedException $e) { + throw $this->adyenGraphQlExceptionMessageFormatter->getFormatted( + $e, + __($this->getGenericErrorMessage()), + $this->getGenericErrorMessage(), + $field, + $context, + $info + ); + } catch (Exception $e) { + $this->adyenLogger->error(sprintf( + '%s: %s', + $this->getGenericErrorMessage(), + $e->getMessage() + )); + throw new GraphQlAdyenException(__($this->getGenericErrorMessage())); + } + } +} diff --git a/Model/Resolver/DonationCampaigns.php b/Model/Resolver/DonationCampaigns.php new file mode 100644 index 0000000000..fb4cd62c3d --- /dev/null +++ b/Model/Resolver/DonationCampaigns.php @@ -0,0 +1,57 @@ + + */ +declare(strict_types=1); + +namespace Adyen\Payment\Model\Resolver; + +use Adyen\Payment\Exception\GraphQlAdyenException; +use Adyen\Payment\Model\Api\AdyenDonationCampaigns; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\OrderInterface; + +class DonationCampaigns extends AbstractDonationResolver +{ + /** + * @return array + */ + protected function getRequiredFields(): array + { + return [ + 'cartId' + ]; + } + + /** + * @param OrderInterface $order + * @param array $args + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @return array + * @throws GraphQlAdyenException|LocalizedException + */ + protected function performOperation( + OrderInterface $order, + array $args, + Field $field, + $context, + ResolveInfo $info + ): array { + $adyenDonationCampaigns = ObjectManager::getInstance()->get(AdyenDonationCampaigns::class); + $campaignsResponse = $adyenDonationCampaigns->getCampaigns((int) $order->getEntityId()); + + return ['campaignsData' => $campaignsResponse]; + } +} diff --git a/Model/Resolver/Donations.php b/Model/Resolver/Donations.php new file mode 100644 index 0000000000..b9df12cfe2 --- /dev/null +++ b/Model/Resolver/Donations.php @@ -0,0 +1,72 @@ + + */ +declare(strict_types=1); + +namespace Adyen\Payment\Model\Resolver; + +use Adyen\Payment\Exception\GraphQlAdyenException; +use Adyen\Payment\Model\Api\AdyenDonations; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\Sales\Api\Data\OrderInterface; + +class Donations extends AbstractDonationResolver +{ + /** + * @return array + */ + protected function getRequiredFields(): array + { + return [ + 'cartId', + 'amount', + 'amount.currency', + 'returnUrl' + ]; + } + + /** + * @param OrderInterface $order + * @param array $args + * @param Field $field + * @param $context + * @param ResolveInfo $info + * @return array + * @throws GraphQlAdyenException|LocalizedException + */ + protected function performOperation( + OrderInterface $order, + array $args, + Field $field, + $context, + ResolveInfo $info + ): array { + $payloadData = [ + 'amount' => [ + 'currency' => $args['amount']['currency'], + 'value' => $args['amount']['value'] + ], + 'returnUrl' => $args['returnUrl'] + ]; + + $jsonSerializer = ObjectManager::getInstance()->get(Json::class); + $payload = $jsonSerializer->serialize($payloadData); + + $adyenDonations = ObjectManager::getInstance()->get(AdyenDonations::class); + $adyenDonations->makeDonation($payload, $order); + + return ['status' => true]; + } +} diff --git a/Test/Unit/Helper/PaymentResponseHandlerTest.php b/Test/Unit/Helper/PaymentResponseHandlerTest.php index 5579bb48ba..738391bc9c 100644 --- a/Test/Unit/Helper/PaymentResponseHandlerTest.php +++ b/Test/Unit/Helper/PaymentResponseHandlerTest.php @@ -118,6 +118,10 @@ public function testFormatPaymentResponseForFinalResultCodes($resultCode) "resultCode" => $resultCode ]; + if ($resultCode === PaymentResponseHandler::AUTHORISED) { + $expectedResult["canDonate"] = false; + } + // Execute method of the tested class $result = $this->paymentResponseHandler->formatPaymentResponse($resultCode); @@ -125,6 +129,28 @@ public function testFormatPaymentResponseForFinalResultCodes($resultCode) $this->assertEquals($expectedResult, $result); } + /** + * @return void + */ + public function testFormatPaymentResponseForAuthorisedWithDonationToken() + { + $expectedResult = [ + "isFinal" => true, + "resultCode" => PaymentResponseHandler::AUTHORISED, + "canDonate" => true + ]; + + // Execute method of the tested class + $result = $this->paymentResponseHandler->formatPaymentResponse( + PaymentResponseHandler::AUTHORISED, + null, + true + ); + + // Assert conditions + $this->assertEquals($expectedResult, $result); + } + public static function dataSourceForFormatPaymentResponseActionRequiredPayments(): array { return [ diff --git a/Test/Unit/Model/GraphqlInputArgumentValidatorTest.php b/Test/Unit/Model/GraphqlInputArgumentValidatorTest.php new file mode 100644 index 0000000000..d10716acf3 --- /dev/null +++ b/Test/Unit/Model/GraphqlInputArgumentValidatorTest.php @@ -0,0 +1,172 @@ + + */ +declare(strict_types=1); + +namespace Adyen\Payment\Test\Unit\Model; + +use Adyen\Payment\Model\GraphqlInputArgumentValidator; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; + +class GraphqlInputArgumentValidatorTest extends AbstractAdyenTestCase +{ + private GraphqlInputArgumentValidator $validator; + + protected function setUp(): void + { + $this->validator = new GraphqlInputArgumentValidator(); + } + + /** + * @return void + * @throws GraphQlInputException + */ + public function testExecuteWithAllRequiredFieldsPresent() + { + $args = [ + 'name' => 'John', + 'email' => 'john@example.com' + ]; + $requiredFields = ['name', 'email']; + + $this->validator->execute($args, $requiredFields); + + // No exception means the test passes + $this->assertTrue(true); + } + + /** + * @return void + * @throws GraphQlInputException + */ + public function testExecuteWithNestedRequiredFieldsPresent() + { + $args = [ + 'payment' => [ + 'method' => 'adyen_cc', + 'details' => [ + 'brand' => 'visa' + ] + ] + ]; + $requiredFields = ['payment.method', 'payment.details.brand']; + + $this->validator->execute($args, $requiredFields); + + $this->assertTrue(true); + } + + /** + * @return void + */ + public function testExecuteThrowsExceptionForMissingTopLevelField() + { + $args = [ + 'name' => 'John' + ]; + $requiredFields = ['name', 'email']; + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('email'); + + $this->validator->execute($args, $requiredFields); + } + + /** + * @return void + */ + public function testExecuteThrowsExceptionForMissingNestedField() + { + $args = [ + 'payment' => [ + 'method' => 'adyen_cc' + ] + ]; + $requiredFields = ['payment.details.brand']; + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('payment.details.brand'); + + $this->validator->execute($args, $requiredFields); + } + + /** + * @return void + */ + public function testExecuteThrowsExceptionWithMultipleMissingFields() + { + $args = [ + 'name' => 'John' + ]; + $requiredFields = ['email', 'phone']; + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('email, phone'); + + $this->validator->execute($args, $requiredFields); + } + + /** + * @return void + */ + public function testExecuteThrowsExceptionForNullArgs() + { + $requiredFields = ['name']; + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('name'); + + $this->validator->execute(null, $requiredFields); + } + + /** + * @return void + */ + public function testExecuteThrowsExceptionForEmptyArgs() + { + $requiredFields = ['name']; + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('name'); + + $this->validator->execute([], $requiredFields); + } + + /** + * @return void + * @throws GraphQlInputException + */ + public function testExecuteWithNoRequiredFields() + { + $args = ['name' => 'John']; + + $this->validator->execute($args, []); + + $this->assertTrue(true); + } + + /** + * @return void + */ + public function testExecuteThrowsExceptionForEmptyStringValue() + { + $args = [ + 'name' => '' + ]; + $requiredFields = ['name']; + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('name'); + + $this->validator->execute($args, $requiredFields); + } +} diff --git a/Test/Unit/Model/Resolver/DonationCampaignsTest.php b/Test/Unit/Model/Resolver/DonationCampaignsTest.php new file mode 100644 index 0000000000..2fed44bdb5 --- /dev/null +++ b/Test/Unit/Model/Resolver/DonationCampaignsTest.php @@ -0,0 +1,259 @@ + + */ +declare(strict_types=1); + +namespace Adyen\Payment\Test\Unit\Model\Resolver; + +use Adyen\Payment\Exception\GraphQlAdyenException; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Model\Api\AdyenDonationCampaigns; +use Adyen\Payment\Model\GraphqlInputArgumentValidator; +use Adyen\Payment\Model\Resolver\DonationCampaigns; +use Adyen\Payment\Model\Sales\OrderRepository; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Exception; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\ContextInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\ObjectManagerInterface; +use Magento\GraphQl\Helper\Error\AggregateExceptionMessageFormatter; +use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\MockObject\MockObject; + +class DonationCampaignsTest extends AbstractAdyenTestCase +{ + private MockObject $adyenDonationCampaignsMock; + private MockObject $maskedQuoteIdToQuoteIdMock; + private MockObject $orderRepositoryMock; + private MockObject $graphqlInputArgumentValidatorMock; + private MockObject $adyenLoggerMock; + private MockObject $adyenGraphQlExceptionMessageFormatterMock; + private MockObject $fieldMock; + private MockObject $contextMock; + private MockObject $infoMock; + private DonationCampaigns $donationCampaignsResolver; + + protected function setUp(): void + { + $this->adyenDonationCampaignsMock = $this->createMock(AdyenDonationCampaigns::class); + $this->maskedQuoteIdToQuoteIdMock = $this->createMock(MaskedQuoteIdToQuoteIdInterface::class); + $this->orderRepositoryMock = $this->createMock(OrderRepository::class); + $this->graphqlInputArgumentValidatorMock = $this->createMock(GraphqlInputArgumentValidator::class); + $this->adyenLoggerMock = $this->createMock(AdyenLogger::class); + $this->adyenGraphQlExceptionMessageFormatterMock = $this->createMock(AggregateExceptionMessageFormatter::class); + $this->fieldMock = $this->createMock(Field::class); + $this->contextMock = $this->createMock(ContextInterface::class); + $this->infoMock = $this->createMock(ResolveInfo::class); + + $objectManagerMock = $this->createMock(ObjectManagerInterface::class); + $objectManagerMock->method('get')->willReturnMap([ + [AdyenDonationCampaigns::class, $this->adyenDonationCampaignsMock] + ]); + ObjectManager::setInstance($objectManagerMock); + + $this->donationCampaignsResolver = new DonationCampaigns( + $this->maskedQuoteIdToQuoteIdMock, + $this->orderRepositoryMock, + $this->graphqlInputArgumentValidatorMock, + $this->adyenLoggerMock, + $this->adyenGraphQlExceptionMessageFormatterMock + ); + } + + public function testResolveSuccessful(): void + { + $args = ['cartId' => 'masked_cart_id']; + $quoteId = 123; + $orderId = 456; + $campaignsResponse = '{"campaigns": [{"id": "1"}]}'; + + $orderMock = $this->createMock(Order::class); + $orderMock->method('getEntityId')->willReturn($orderId); + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn($orderMock); + + $this->adyenDonationCampaignsMock->expects($this->once()) + ->method('getCampaigns') + ->with($orderId) + ->willReturn($campaignsResponse); + + $result = $this->donationCampaignsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + + $this->assertEquals(['campaignsData' => $campaignsResponse], $result); + } + + public function testResolveThrowsExceptionForMissingRequiredFields(): void + { + $args = []; + + $this->graphqlInputArgumentValidatorMock->method('execute') + ->willThrowException(new GraphQlInputException(__('Required parameters "cartId" are missing'))); + + $this->expectException(GraphQlInputException::class); + + $this->donationCampaignsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsExceptionWhenQuoteNotFound(): void + { + $args = ['cartId' => 'invalid_masked_id']; + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('invalid_masked_id') + ->willThrowException(new NoSuchEntityException()); + + $this->adyenLoggerMock->expects($this->once())->method('error'); + + $this->expectException(GraphQlAdyenException::class); + + $this->donationCampaignsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsExceptionWhenOrderNotFound(): void + { + $args = ['cartId' => 'masked_cart_id']; + $quoteId = 123; + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn(null); + + $this->adyenLoggerMock->expects($this->once())->method('error'); + + $this->expectException(GraphQlAdyenException::class); + + $this->donationCampaignsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsFormattedExceptionOnLocalizedException(): void + { + $args = ['cartId' => 'masked_cart_id']; + $quoteId = 123; + $orderId = 456; + + $orderMock = $this->createMock(Order::class); + $orderMock->method('getEntityId')->willReturn($orderId); + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn($orderMock); + + $localizedException = new LocalizedException(__('Unable to retrieve donation campaigns.')); + + $this->adyenDonationCampaignsMock->method('getCampaigns') + ->with($orderId) + ->willThrowException($localizedException); + + $formattedException = new GraphQlInputException(__('An error occurred while processing the donation.')); + + $this->adyenGraphQlExceptionMessageFormatterMock + ->expects($this->once()) + ->method('getFormatted') + ->with( + $this->equalTo($localizedException), + $this->anything(), + $this->equalTo('An error occurred while processing the donation.'), + $this->equalTo($this->fieldMock), + $this->equalTo($this->contextMock), + $this->equalTo($this->infoMock) + ) + ->willReturn($formattedException); + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('An error occurred while processing the donation.'); + + $this->donationCampaignsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsAdyenExceptionOnGenericException(): void + { + $args = ['cartId' => 'masked_cart_id']; + $quoteId = 123; + $orderId = 456; + + $orderMock = $this->createMock(Order::class); + $orderMock->method('getEntityId')->willReturn($orderId); + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn($orderMock); + + $this->adyenDonationCampaignsMock->method('getCampaigns') + ->with($orderId) + ->willThrowException(new Exception('Unexpected error')); + + $this->adyenLoggerMock->expects($this->once())->method('error'); + + $this->expectException(GraphQlAdyenException::class); + + $this->donationCampaignsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } +} diff --git a/Test/Unit/Model/Resolver/DonationsTest.php b/Test/Unit/Model/Resolver/DonationsTest.php new file mode 100644 index 0000000000..547739b348 --- /dev/null +++ b/Test/Unit/Model/Resolver/DonationsTest.php @@ -0,0 +1,301 @@ + + */ +declare(strict_types=1); + +namespace Adyen\Payment\Test\Unit\Model\Resolver; + +use Adyen\Payment\Exception\GraphQlAdyenException; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Model\Api\AdyenDonations; +use Adyen\Payment\Model\GraphqlInputArgumentValidator; +use Adyen\Payment\Model\Resolver\Donations; +use Adyen\Payment\Model\Sales\OrderRepository; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Exception; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\ContextInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\GraphQl\Helper\Error\AggregateExceptionMessageFormatter; +use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\MockObject\MockObject; + +class DonationsTest extends AbstractAdyenTestCase +{ + private MockObject $adyenDonationsMock; + private MockObject $maskedQuoteIdToQuoteIdMock; + private MockObject $orderRepositoryMock; + private MockObject $jsonSerializerMock; + private MockObject $graphqlInputArgumentValidatorMock; + private MockObject $adyenLoggerMock; + private MockObject $adyenGraphQlExceptionMessageFormatterMock; + private MockObject $fieldMock; + private MockObject $contextMock; + private MockObject $infoMock; + private Donations $donationsResolver; + + protected function setUp(): void + { + $this->adyenDonationsMock = $this->createMock(AdyenDonations::class); + $this->maskedQuoteIdToQuoteIdMock = $this->createMock(MaskedQuoteIdToQuoteIdInterface::class); + $this->orderRepositoryMock = $this->createMock(OrderRepository::class); + $this->jsonSerializerMock = $this->createMock(Json::class); + $this->graphqlInputArgumentValidatorMock = $this->createMock(GraphqlInputArgumentValidator::class); + $this->adyenLoggerMock = $this->createMock(AdyenLogger::class); + $this->adyenGraphQlExceptionMessageFormatterMock = $this->createMock(AggregateExceptionMessageFormatter::class); + $this->fieldMock = $this->createMock(Field::class); + $this->contextMock = $this->createMock(ContextInterface::class); + $this->infoMock = $this->createMock(ResolveInfo::class); + + $objectManagerMock = $this->createMock(ObjectManagerInterface::class); + $objectManagerMock->method('get')->willReturnMap([ + [Json::class, $this->jsonSerializerMock], + [AdyenDonations::class, $this->adyenDonationsMock] + ]); + ObjectManager::setInstance($objectManagerMock); + + $this->donationsResolver = new Donations( + $this->maskedQuoteIdToQuoteIdMock, + $this->orderRepositoryMock, + $this->graphqlInputArgumentValidatorMock, + $this->adyenLoggerMock, + $this->adyenGraphQlExceptionMessageFormatterMock + ); + } + + public function testResolveSuccessful(): void + { + $args = [ + 'cartId' => 'masked_cart_id', + 'amount' => [ + 'currency' => 'EUR', + 'value' => 500 + ], + 'returnUrl' => 'https://example.com/return' + ]; + + $quoteId = 123; + $orderMock = $this->createMock(Order::class); + $serializedPayload = '{"amount":{"currency":"EUR","value":500},"returnUrl":"https:\/\/example.com\/return"}'; + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn($orderMock); + + $this->jsonSerializerMock->method('serialize') + ->willReturn($serializedPayload); + + $this->adyenDonationsMock->expects($this->once()) + ->method('makeDonation') + ->with($serializedPayload, $orderMock); + + $result = $this->donationsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + + $this->assertEquals(['status' => true], $result); + } + + public function testResolveThrowsExceptionForMissingRequiredFields(): void + { + $args = [ + 'cartId' => 'masked_cart_id' + ]; + + $this->graphqlInputArgumentValidatorMock->method('execute') + ->willThrowException(new GraphQlInputException(__('Required parameters "amount, returnUrl" are missing'))); + + $this->expectException(GraphQlInputException::class); + + $this->donationsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsExceptionWhenQuoteNotFound(): void + { + $args = [ + 'cartId' => 'invalid_masked_id', + 'amount' => [ + 'currency' => 'EUR', + 'value' => 500 + ], + 'returnUrl' => 'https://example.com/return' + ]; + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('invalid_masked_id') + ->willThrowException(new NoSuchEntityException()); + + $this->adyenLoggerMock->expects($this->once())->method('error'); + + $this->expectException(GraphQlAdyenException::class); + + $this->donationsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsExceptionWhenOrderNotFound(): void + { + $args = [ + 'cartId' => 'masked_cart_id', + 'amount' => [ + 'currency' => 'EUR', + 'value' => 500 + ], + 'returnUrl' => 'https://example.com/return' + ]; + + $quoteId = 123; + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn(null); + + $this->adyenLoggerMock->expects($this->once())->method('error'); + + $this->expectException(GraphQlAdyenException::class); + + $this->donationsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsFormattedExceptionOnLocalizedException(): void + { + $args = [ + 'cartId' => 'masked_cart_id', + 'amount' => [ + 'currency' => 'EUR', + 'value' => 500 + ], + 'returnUrl' => 'https://example.com/return' + ]; + + $quoteId = 123; + $orderMock = $this->createMock(Order::class); + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn($orderMock); + + $this->jsonSerializerMock->method('serialize') + ->willReturn('{}'); + + $localizedException = new LocalizedException(__('Donation failed!')); + + $this->adyenDonationsMock->method('makeDonation') + ->willThrowException($localizedException); + + $formattedException = new GraphQlInputException(__('An error occurred while processing the donation.')); + + $this->adyenGraphQlExceptionMessageFormatterMock + ->expects($this->once()) + ->method('getFormatted') + ->with( + $this->equalTo($localizedException), + $this->anything(), + $this->equalTo('An error occurred while processing the donation.'), + $this->equalTo($this->fieldMock), + $this->equalTo($this->contextMock), + $this->equalTo($this->infoMock) + ) + ->willReturn($formattedException); + + $this->expectException(GraphQlInputException::class); + $this->expectExceptionMessage('An error occurred while processing the donation.'); + + $this->donationsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } + + public function testResolveThrowsAdyenExceptionOnGenericException(): void + { + $args = [ + 'cartId' => 'masked_cart_id', + 'amount' => [ + 'currency' => 'EUR', + 'value' => 500 + ], + 'returnUrl' => 'https://example.com/return' + ]; + + $quoteId = 123; + $orderMock = $this->createMock(Order::class); + + $this->maskedQuoteIdToQuoteIdMock->method('execute') + ->with('masked_cart_id') + ->willReturn($quoteId); + + $this->orderRepositoryMock->method('getOrderByQuoteId') + ->with($quoteId) + ->willReturn($orderMock); + + $this->jsonSerializerMock->method('serialize') + ->willReturn('{}'); + + $this->adyenDonationsMock->method('makeDonation') + ->willThrowException(new Exception('Unexpected error')); + + $this->adyenLoggerMock->expects($this->once())->method('error'); + + $this->expectException(GraphQlAdyenException::class); + + $this->donationsResolver->resolve( + $this->fieldMock, + $this->contextMock, + $this->infoMock, + null, + $args + ); + } +} diff --git a/etc/graphql/di.xml b/etc/graphql/di.xml index 08bc8e1a49..2da51ce653 100644 --- a/etc/graphql/di.xml +++ b/etc/graphql/di.xml @@ -171,4 +171,14 @@ AdyenGraphQlExceptionMessageFormatter + + + AdyenGraphQlExceptionMessageFormatter + + + + + AdyenGraphQlExceptionMessageFormatter + + diff --git a/etc/schema.graphqls b/etc/schema.graphqls index cce10008b2..b9a4e56bbd 100644 --- a/etc/schema.graphqls +++ b/etc/schema.graphqls @@ -18,6 +18,10 @@ type Query { adyenRedeemedGiftcards ( cartId: String! @doc(description: "Cart ID for which to fetch redeemed gift cards.") ): AdyenRedeemedGiftcardsResponse @resolver(class: "Adyen\\Payment\\Model\\Resolver\\GetAdyenRedeemedGiftcards") + + adyenDonationCampaigns ( + cartId: String! @doc(description: "Cart ID associated with the donation") + ): AdyenDonationCampaignsResponse @resolver(class: "Adyen\\Payment\\Model\\Resolver\\DonationCampaigns") } type Mutation { @@ -39,6 +43,25 @@ type Mutation { posPayment( cartId: String! @doc(description: "Cart ID associated with the POS payment.") ): AdyenPaymentStatus @resolver(class: "Adyen\\Payment\\Model\\Resolver\\InitiatePosPayment") + + adyenDonate( + cartId: String! @doc(description: "Cart ID associated with the donation") + amount: AdyenAmountInput! @doc(description: "Donation amount") + returnUrl: String! @doc(description: "The URL to return to after the donation is completed in case of a redirect") + ): AdyenDonationStatus @resolver(class: "Adyen\\Payment\\Model\\Resolver\\Donations") +} + +input AdyenAmountInput { + currency: String! @doc(description: "Three-letter ISO currency code, e.g. EUR.") + value: Int! @doc(description: "Amount in minor units, e.g. 500 for 5.00 EUR.") +} + +type AdyenDonationStatus { + status: Boolean @doc(description: "True if the donation was processed successfully.") +} + +type AdyenDonationCampaignsResponse { + campaignsData: String @doc(description: "JSON encoded donation campaigns data.") } type AdyenPaymentStatus { @@ -46,6 +69,7 @@ type AdyenPaymentStatus { resultCode: String @doc(description: "Current state of the order in Adyen.") additionalData: String @doc(description: "Additional data required for the next step in the payment process.") action: String @doc(description: "Object containing information about the payment's next step.") + canDonate: Boolean @doc(description: "Indicates if donations are available for this order.") } type AdyenPaymentMethods {