From 78f04a72b5d10f4b9dc9f647f8e596676f8c0de1 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Fri, 6 Mar 2026 16:21:55 +0200 Subject: [PATCH 01/14] fix: scope PaymentManagement DI to frontend/webapi_rest to prevent EE admin crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PaymentManagementInterface preference was registered in the global etc/di.xml, making its implementation (PaymentManagement) resolvable in all areas including admin. PaymentManagement depends on Magento\Checkout\Model\Session — a frontend-only session class that cannot be properly instantiated in the admin area. On Adobe Commerce EE 2.4.8, the admin "Create New Order" page triggers resolution of this dependency chain, causing a PHP exception that crashes the entire page (6 MFTF test failures). Changes: - Move PaymentManagementInterface preference from global di.xml to etc/frontend/di.xml and etc/webapi_rest/di.xml - Inject Checkout\Session via Proxy to defer instantiation until actually needed (defense in depth) - Bump version to 3.0.5 --- composer.json | 2 +- etc/di.xml | 4 ---- etc/frontend/di.xml | 10 +++++++++- etc/module.xml | 2 +- etc/webapi_rest/di.xml | 10 ++++++++++ 5 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 etc/webapi_rest/di.xml diff --git a/composer.json b/composer.json index c834533..7936eec 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "pstk/paystack-magento2-module", "description": "Paystack Magento2 Module using \\Magento\\Payment\\Model\\Method\\AbstractMethod", - "version": "3.0.4", + "version": "3.0.5", "require": {}, "type": "magento2-module", "license": [ diff --git a/etc/di.xml b/etc/di.xml index 0b62035..38c1841 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -1,8 +1,4 @@ - - - - diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml index 888e464..31eed27 100644 --- a/etc/frontend/di.xml +++ b/etc/frontend/di.xml @@ -1,5 +1,13 @@ + + + + + Magento\Checkout\Model\Session\Proxy + + + @@ -11,7 +19,7 @@ - + diff --git a/etc/module.xml b/etc/module.xml index b016bf6..3c45ec9 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ - + diff --git a/etc/webapi_rest/di.xml b/etc/webapi_rest/di.xml new file mode 100644 index 0000000..2766c27 --- /dev/null +++ b/etc/webapi_rest/di.xml @@ -0,0 +1,10 @@ + + + + + + + Magento\Checkout\Model\Session\Proxy + + + From bf211952300a224770153ff37ec58374d603263c Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Fri, 6 Mar 2026 16:40:12 +0200 Subject: [PATCH 02/14] fix: lazy-load payment method to prevent checkout page crash in MFTF tests ConfigProvider constructor eagerly called getMethodInstance() which crashed the entire checkout page if it threw. Now deferred to getConfig() with try-catch so the checkout page always renders. Same lazy-load pattern applied to PaystackApiClient and AbstractPaystackStandard. Replaced concrete Store model with StoreManagerInterface. --- .../Payment/AbstractPaystackStandard.php | 23 +++- Controller/Payment/Setup.php | 4 +- Gateway/PaystackApiClient.php | 29 +++-- Model/Ui/ConfigProvider.php | 105 +++++++++++------- composer.json | 2 +- etc/module.xml | 2 +- 6 files changed, 111 insertions(+), 54 deletions(-) diff --git a/Controller/Payment/AbstractPaystackStandard.php b/Controller/Payment/AbstractPaystackStandard.php index 26d3cee..43a267f 100644 --- a/Controller/Payment/AbstractPaystackStandard.php +++ b/Controller/Payment/AbstractPaystackStandard.php @@ -23,6 +23,7 @@ namespace Pstk\Paystack\Controller\Payment; use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Store\Model\StoreManagerInterface; use Pstk\Paystack\Gateway\PaystackApiClient; @@ -42,6 +43,7 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac */ protected $orderInterface; protected $checkoutSession; + protected $paymentHelper; protected $method; protected $messageManager; @@ -51,6 +53,12 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac */ protected $configProvider; + /** + * + * @var StoreManagerInterface + */ + protected $storeManager; + /** * * @var PaystackApiClient @@ -89,6 +97,7 @@ public function __construct( PaymentHelper $paymentHelper, \Magento\Framework\Message\ManagerInterface $messageManager, \Pstk\Paystack\Model\Ui\ConfigProvider $configProvider, + StoreManagerInterface $storeManager, \Magento\Framework\Event\Manager $eventManager, \Magento\Framework\App\Request\Http $request, \Psr\Log\LoggerInterface $logger, @@ -98,9 +107,10 @@ public function __construct( $this->orderRepository = $orderRepository; $this->orderInterface = $orderInterface; $this->checkoutSession = $checkoutSession; - $this->method = $paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE); + $this->paymentHelper = $paymentHelper; $this->messageManager = $messageManager; $this->configProvider = $configProvider; + $this->storeManager = $storeManager; $this->eventManager = $eventManager; $this->request = $request; $this->logger = $logger; @@ -108,6 +118,17 @@ public function __construct( parent::__construct($context); } + + /** + * @return \Magento\Payment\Model\MethodInterface + */ + protected function getMethod() + { + if ($this->method === null) { + $this->method = $this->paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE); + } + return $this->method; + } protected function redirectToFinal($successFul = true, $message="") { if($successFul){ diff --git a/Controller/Payment/Setup.php b/Controller/Payment/Setup.php index 128c6b7..f7ac7fa 100644 --- a/Controller/Payment/Setup.php +++ b/Controller/Payment/Setup.php @@ -33,7 +33,7 @@ public function execute() { $message = ''; $order = $this->orderInterface->loadByIncrementId($this->checkoutSession->getLastRealOrder()->getIncrementId()); - if ($order && $this->method->getCode() == $order->getPayment()->getMethod()) { + if ($order && $this->getMethod()->getCode() == $order->getPayment()->getMethod()) { try { return $this->processAuthorization($order); @@ -55,7 +55,7 @@ protected function processAuthorization(\Magento\Sales\Model\Order $order) { 'email' => $order->getCustomerEmail(), // unique to customers 'reference' => $order->getIncrementId(), // unique to transactions 'currency' => $order->getCurrency(), - 'callback_url' => $this->configProvider->getStore()->getBaseUrl() . "paystack/payment/callback", + 'callback_url' => $this->storeManager->getStore()->getBaseUrl() . "paystack/payment/callback", 'metadata' => array('custom_fields' => array( array( "display_name"=>"Plugin", diff --git a/Gateway/PaystackApiClient.php b/Gateway/PaystackApiClient.php index 9991848..9f3372f 100644 --- a/Gateway/PaystackApiClient.php +++ b/Gateway/PaystackApiClient.php @@ -10,16 +10,31 @@ class PaystackApiClient { private const BASE_URL = 'https://api.paystack.co'; - /** @var string */ + /** @var PaymentHelper */ + private $paymentHelper; + + /** @var string|null */ private $secretKey; public function __construct(PaymentHelper $paymentHelper) { - $method = $paymentHelper->getMethodInstance(PaystackModel::CODE); - $this->secretKey = $method->getConfigData('live_secret_key'); - if ($method->getConfigData('test_mode')) { - $this->secretKey = $method->getConfigData('test_secret_key'); + $this->paymentHelper = $paymentHelper; + } + + /** + * @return string + */ + private function getSecretKey(): string + { + if ($this->secretKey === null) { + $method = $this->paymentHelper->getMethodInstance(PaystackModel::CODE); + $this->secretKey = $method->getConfigData('live_secret_key'); + if ($method->getConfigData('test_mode')) { + $this->secretKey = $method->getConfigData('test_secret_key'); + } + $this->secretKey = (string) $this->secretKey; } + return $this->secretKey; } /** @@ -55,7 +70,7 @@ public function verifyTransaction(string $reference): object */ public function validateWebhookSignature(string $rawBody, string $signature): bool { - $computed = hash_hmac('sha512', $rawBody, $this->secretKey); + $computed = hash_hmac('sha512', $rawBody, $this->getSecretKey()); return hash_equals($computed, $signature); } @@ -104,7 +119,7 @@ private function request(string $method, string $endpoint, ?array $data = null): CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ - 'Authorization: Bearer ' . $this->secretKey, + 'Authorization: Bearer ' . $this->getSecretKey(), 'Content-Type: application/json', ], CURLOPT_TIMEOUT => 30, diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php index bdb0681..6b3c155 100644 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -3,21 +3,36 @@ use Magento\Checkout\Model\ConfigProviderInterface; use Magento\Payment\Helper\Data as PaymentHelper; -use Magento\Store\Model\Store as Store; +use Magento\Store\Model\StoreManagerInterface; +use Psr\Log\LoggerInterface; -/** - * Class ConfigProvider - */ class ConfigProvider implements ConfigProviderInterface { - protected $method; - protected $store; + protected $paymentHelper; + protected $storeManager; + protected $logger; + private $method; - public function __construct(PaymentHelper $paymentHelper, Store $store) + public function __construct( + PaymentHelper $paymentHelper, + StoreManagerInterface $storeManager, + LoggerInterface $logger + ) { + $this->paymentHelper = $paymentHelper; + $this->storeManager = $storeManager; + $this->logger = $logger; + } + + /** + * @return \Magento\Payment\Model\MethodInterface + */ + private function getMethod() { - $this->method = $paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE); - $this->store = $store; + if ($this->method === null) { + $this->method = $this->paymentHelper->getMethodInstance(\Pstk\Paystack\Model\Payment\Paystack::CODE); + } + return $this->method; } /** @@ -27,51 +42,57 @@ public function __construct(PaymentHelper $paymentHelper, Store $store) */ public function getConfig() { - $publicKey = $this->method->getConfigData('live_public_key'); - if ($this->method->getConfigData('test_mode')) { - $publicKey = $this->method->getConfigData('test_public_key'); - } - - $integrationType = $this->method->getConfigData('integration_type')?: 'inline'; + try { + $method = $this->getMethod(); + + $publicKey = $method->getConfigData('live_public_key'); + if ($method->getConfigData('test_mode')) { + $publicKey = $method->getConfigData('test_public_key'); + } + + $integrationType = $method->getConfigData('integration_type') ?: 'inline'; + $baseUrl = $this->storeManager->getStore()->getBaseUrl(); - return [ - 'payment' => [ - \Pstk\Paystack\Model\Payment\Paystack::CODE => [ - 'public_key' => $publicKey, - 'integration_type' => $integrationType, - 'api_url' => $this->store->getBaseUrl() . 'rest/', - 'integration_type_standard_url' => $this->store->getBaseUrl() . 'paystack/payment/setup', - 'recreate_quote_url' => $this->store->getBaseUrl() . 'paystack/payment/recreate', + return [ + 'payment' => [ + \Pstk\Paystack\Model\Payment\Paystack::CODE => [ + 'public_key' => $publicKey, + 'integration_type' => $integrationType, + 'api_url' => $baseUrl . 'rest/', + 'integration_type_standard_url' => $baseUrl . 'paystack/payment/setup', + 'recreate_quote_url' => $baseUrl . 'paystack/payment/recreate', + ] ] - ] - ]; - } - - public function getStore() { - return $this->store; + ]; + } catch (\Exception $e) { + $this->logger->error('Paystack ConfigProvider: ' . $e->getMessage()); + return []; + } } - + /** * Get secret key for webhook process - * + * * @return array */ - public function getSecretKeyArray(){ - $data = ["live" => $this->method->getConfigData('live_secret_key')]; - if ($this->method->getConfigData('test_mode')) { - $data = ["test" => $this->method->getConfigData('test_secret_key')]; + public function getSecretKeyArray() + { + $method = $this->getMethod(); + $data = ["live" => $method->getConfigData('live_secret_key')]; + if ($method->getConfigData('test_mode')) { + $data = ["test" => $method->getConfigData('test_secret_key')]; } - + return $data; } - public function getPublicKey(){ - $publicKey = $this->method->getConfigData('live_public_key'); - if ($this->method->getConfigData('test_mode')) { - $publicKey = $this->method->getConfigData('test_public_key'); + public function getPublicKey() + { + $method = $this->getMethod(); + $publicKey = $method->getConfigData('live_public_key'); + if ($method->getConfigData('test_mode')) { + $publicKey = $method->getConfigData('test_public_key'); } return $publicKey; } - - } diff --git a/composer.json b/composer.json index 7936eec..65cdd0a 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "pstk/paystack-magento2-module", "description": "Paystack Magento2 Module using \\Magento\\Payment\\Model\\Method\\AbstractMethod", - "version": "3.0.5", + "version": "3.0.6", "require": {}, "type": "magento2-module", "license": [ diff --git a/etc/module.xml b/etc/module.xml index 3c45ec9..f492e80 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ - + From c18bc11fa701abcea6f7903a0b1e903cda78b769 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Thu, 12 Mar 2026 20:18:21 +0200 Subject: [PATCH 03/14] MFTF Vendor Supplied test passed --- .gitignore | 1 + Block/System/Config/Form/Field/Webhook.php | 30 +- Model/Payment/Paystack.php | 326 +++++++++++++++++- Test/Mftf/Page/PaystackPaymentConfigPage.xml | 13 + .../Section/PaystackPaymentConfigSection.xml | 13 + .../PaystackPaymentConfigAvailableTest.xml | 33 ++ 6 files changed, 379 insertions(+), 37 deletions(-) create mode 100644 Test/Mftf/Page/PaystackPaymentConfigPage.xml create mode 100644 Test/Mftf/Section/PaystackPaymentConfigSection.xml create mode 100644 Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml diff --git a/.gitignore b/.gitignore index aba8ab0..909651c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ composer.phar # Environment .env dev/.env +docs/ diff --git a/Block/System/Config/Form/Field/Webhook.php b/Block/System/Config/Form/Field/Webhook.php index 0ba0010..f230fb2 100644 --- a/Block/System/Config/Form/Field/Webhook.php +++ b/Block/System/Config/Form/Field/Webhook.php @@ -23,32 +23,13 @@ namespace Pstk\Paystack\Block\System\Config\Form\Field; use Magento\Framework\Data\Form\Element\AbstractElement; -use Magento\Store\Model\Store as Store; /** - * Backend system config datetime field renderer - * - * @api - * @since 100.0.2 + * Backend system config field renderer for displaying the Paystack webhook URL. */ class Webhook extends \Magento\Config\Block\System\Config\Form\Field { - /** - * @param \Magento\Backend\Block\Template\Context $context - * @param Store $store - * @param array $data - */ - public function __construct( - \Magento\Backend\Block\Template\Context $context, - Store $store, - array $data = [] - ) { - $this->store = $store; - - parent::__construct($context, $data); - } - /** * Returns element html * @@ -57,10 +38,15 @@ public function __construct( */ protected function _getElementHtml(AbstractElement $element) { - $webhookUrl = $this->store->getBaseUrl() . 'paystack/payment/webhook'; + try { + $webhookUrl = $this->_storeManager->getStore()->getBaseUrl() . 'paystack/payment/webhook'; + } catch (\Exception $e) { + $webhookUrl = '{{store_base_url}}paystack/payment/webhook'; + } + $value = "You may login to Paystack Developer Settings to update your Webhook URL to:

" . "$webhookUrl"; - + $element->setValue($webhookUrl); return $value; diff --git a/Model/Payment/Paystack.php b/Model/Payment/Paystack.php index 0a72f67..fe4062f 100644 --- a/Model/Payment/Paystack.php +++ b/Model/Payment/Paystack.php @@ -1,42 +1,338 @@ . */ namespace Pstk\Paystack\Model\Payment; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\DataObject; +use Magento\Payment\Model\InfoInterface; +use Magento\Payment\Model\MethodInterface; +use Magento\Quote\Api\Data\CartInterface; +use Magento\Store\Model\ScopeInterface; + /** - * Paystack main payment method model - * - * @author Olayode Ezekiel + * Paystack payment method model. + * + * Implements MethodInterface directly (does not extend AbstractMethod) so that + * Adobe Commerce EE plugins on AbstractMethod cannot be inherited by this class. + * This prevents EE-specific interceptors with frontend-only dependencies from + * crashing admin pages such as the order-creation customer-selection grid. */ -class Paystack extends \Magento\Payment\Model\Method\AbstractMethod +class Paystack extends DataObject implements MethodInterface { - const CODE = 'pstk_paystack'; - + + /** @var string */ protected $_code = self::CODE; - protected $_isOffline = true; - public function isAvailable( - ?\Magento\Quote\Api\Data\CartInterface $quote = null + /** @var string */ + protected $_infoBlockType = \Magento\Payment\Block\Info::class; + + /** @var InfoInterface|null */ + private $infoInstance; + + /** @var int|string|null */ + private $storeId; + + /** @var ScopeConfigInterface */ + private $scopeConfig; + + public function __construct( + ScopeConfigInterface $scopeConfig, + array $data = [] ) { - return parent::isAvailable($quote); + $this->scopeConfig = $scopeConfig; + parent::__construct($data); + } + + // ------------------------------------------------------------------------- + // Identity + // ------------------------------------------------------------------------- + + public function getCode() + { + return $this->_code; + } + + // ------------------------------------------------------------------------- + // Store scope + // ------------------------------------------------------------------------- + + public function setStore($storeId) + { + $this->storeId = $storeId; + return $this; + } + + public function getStore() + { + return $this->storeId; + } + + // ------------------------------------------------------------------------- + // Configuration helpers + // ------------------------------------------------------------------------- + + public function getConfigData($field, $storeId = null) + { + if ($storeId === null) { + $storeId = $this->getStore(); + } + $path = 'payment/' . $this->getCode() . '/' . $field; + return $this->scopeConfig->getValue($path, ScopeInterface::SCOPE_STORE, $storeId); + } + + public function getTitle() + { + return $this->getConfigData('title'); + } + + // ------------------------------------------------------------------------- + // Availability checks + // ------------------------------------------------------------------------- + + public function isActive($storeId = null) + { + return (bool)(int)$this->getConfigData('active', $storeId); + } + + public function isAvailable(?CartInterface $quote = null) + { + $storeId = $quote ? $quote->getStoreId() : null; + return $this->isActive($storeId) && $this->canUseCheckout(); + } + + public function canUseInternal() + { + return false; + } + + public function canUseCheckout() + { + $value = $this->getConfigData('can_use_checkout'); + return $value === null ? true : (bool)(int)$value; + } + + public function canUseForCountry($country) + { + if (!$country) { + return true; + } + if ((int)$this->getConfigData('allowspecific') === 1) { + $specificCountries = explode(',', (string)$this->getConfigData('specificcountry')); + if (!in_array($country, $specificCountries, true)) { + return false; + } + } + return true; + } + + public function canUseForCurrency($currencyCode) + { + return true; + } + + // ------------------------------------------------------------------------- + // Capability flags — Paystack is an external redirect gateway + // ------------------------------------------------------------------------- + + public function isGateway() + { + return true; + } + + public function isOffline() + { + return false; + } + + public function isInitializeNeeded() + { + return false; + } + + public function canOrder() + { + return false; + } + + public function canAuthorize() + { + return false; + } + + public function canCapture() + { + return false; + } + + public function canCapturePartial() + { + return false; + } + + public function canCaptureOnce() + { + return false; + } + + public function canRefund() + { + return false; + } + + public function canRefundPartialPerInvoice() + { + return false; + } + + public function canVoid() + { + return false; + } + + public function canEdit() + { + return true; + } + + public function canFetchTransactionInfo() + { + return false; + } + + public function canReviewPayment() + { + return false; + } + + // ------------------------------------------------------------------------- + // Block types + // ------------------------------------------------------------------------- + + public function getFormBlockType() + { + return \Magento\Payment\Block\Form::class; + } + + public function getInfoBlockType() + { + return $this->_infoBlockType; + } + + // ------------------------------------------------------------------------- + // Info instance (used by Magento to store payment data on quotes/orders) + // ------------------------------------------------------------------------- + + public function getInfoInstance() + { + return $this->infoInstance; + } + + public function setInfoInstance(InfoInterface $info) + { + $this->infoInstance = $info; + return $this; + } + + // ------------------------------------------------------------------------- + // Payment actions — Paystack processes payments externally + // ------------------------------------------------------------------------- + + public function validate() + { + return $this; + } + + public function assignData(DataObject $data) + { + return $this; + } + + public function initialize($paymentAction, $stateObject) + { + return $this; + } + + public function getConfigPaymentAction() + { + return $this->getConfigData('payment_action'); + } + + public function fetchTransactionInfo(InfoInterface $payment, $transactionId) + { + return []; + } + + public function order(InfoInterface $payment, $amount) + { + throw new \Magento\Framework\Exception\LocalizedException( + __('Order action is not supported by Paystack.') + ); + } + + public function authorize(InfoInterface $payment, $amount) + { + throw new \Magento\Framework\Exception\LocalizedException( + __('Authorize action is not supported by Paystack.') + ); + } + + public function capture(InfoInterface $payment, $amount) + { + throw new \Magento\Framework\Exception\LocalizedException( + __('Capture action is not supported by Paystack.') + ); + } + + public function refund(InfoInterface $payment, $creditMemo) + { + throw new \Magento\Framework\Exception\LocalizedException( + __('Refund action is not supported by Paystack.') + ); + } + + public function cancel(InfoInterface $payment) + { + return $this; + } + + public function void(InfoInterface $payment) + { + throw new \Magento\Framework\Exception\LocalizedException( + __('Void action is not supported by Paystack.') + ); + } + + public function acceptPayment(InfoInterface $payment) + { + throw new \Magento\Framework\Exception\LocalizedException( + __('Accept payment action is not supported by Paystack.') + ); + } + + public function denyPayment(InfoInterface $payment) + { + throw new \Magento\Framework\Exception\LocalizedException( + __('Deny payment action is not supported by Paystack.') + ); } } diff --git a/Test/Mftf/Page/PaystackPaymentConfigPage.xml b/Test/Mftf/Page/PaystackPaymentConfigPage.xml new file mode 100644 index 0000000..9e9ef3c --- /dev/null +++ b/Test/Mftf/Page/PaystackPaymentConfigPage.xml @@ -0,0 +1,13 @@ + + + + +
+ + diff --git a/Test/Mftf/Section/PaystackPaymentConfigSection.xml b/Test/Mftf/Section/PaystackPaymentConfigSection.xml new file mode 100644 index 0000000..3832cf9 --- /dev/null +++ b/Test/Mftf/Section/PaystackPaymentConfigSection.xml @@ -0,0 +1,13 @@ + + + +
+ +
+
diff --git a/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml new file mode 100644 index 0000000..140f7a5 --- /dev/null +++ b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml @@ -0,0 +1,33 @@ + + + + + + + + + <description value="Verify Paystack appears in Stores > Configuration > Sales > Payment Methods when the extension is installed."/> + <severity value="MAJOR"/> + <group value="Paystack"/> + </annotations> + + <before> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/> + <magentoCLI command="cache:clean config" stepKey="cleanConfigCache"/> + </before> + + <after> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> + </after> + + <amOnPage url="{{PaystackPaymentConfigPage.url}}" stepKey="goToPaymentConfig"/> + <waitForPageLoad time="60" stepKey="waitForPaymentConfig"/> + <see userInput="Paystack" stepKey="seePaystackSection"/> + </test> +</tests> From 8afb0e831d8541e6291beba20afa8eff2a37d5f1 Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Thu, 12 Mar 2026 21:09:09 +0200 Subject: [PATCH 04/14] Added end to end testing and fixed versions in README.md --- README.md | 2 +- Test/Unit/Controller/Payment/CallbackTest.php | 178 +++++++++ Test/Unit/Controller/Payment/RecreateTest.php | 123 ++++++ Test/Unit/Controller/Payment/SetupTest.php | 206 ++++++++++ Test/Unit/Controller/Payment/WebhookTest.php | 308 +++++++++++++++ Test/Unit/Gateway/PaystackApiClientTest.php | 108 ++++++ Test/Unit/Model/CspPolicyCollectorTest.php | 59 +++ Test/Unit/Model/Payment/PaystackTest.php | 353 ++++++++++++++++++ Test/Unit/Model/PaymentManagementTest.php | 204 ++++++++++ Test/Unit/Model/Ui/ConfigProviderTest.php | 185 +++++++++ .../ObserverAfterPaymentVerifyTest.php | 106 ++++++ .../ObserverBeforeSalesOrderPlaceTest.php | 79 ++++ Test/Unit/Plugin/CsrfValidatorSkipTest.php | 76 ++++ phpunit.xml | 23 ++ 14 files changed, 2009 insertions(+), 1 deletion(-) create mode 100644 Test/Unit/Controller/Payment/CallbackTest.php create mode 100644 Test/Unit/Controller/Payment/RecreateTest.php create mode 100644 Test/Unit/Controller/Payment/SetupTest.php create mode 100644 Test/Unit/Controller/Payment/WebhookTest.php create mode 100644 Test/Unit/Gateway/PaystackApiClientTest.php create mode 100644 Test/Unit/Model/CspPolicyCollectorTest.php create mode 100644 Test/Unit/Model/Payment/PaystackTest.php create mode 100644 Test/Unit/Model/PaymentManagementTest.php create mode 100644 Test/Unit/Model/Ui/ConfigProviderTest.php create mode 100644 Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php create mode 100644 Test/Unit/Observer/ObserverBeforeSalesOrderPlaceTest.php create mode 100644 Test/Unit/Plugin/CsrfValidatorSkipTest.php create mode 100644 phpunit.xml diff --git a/README.md b/README.md index 6e04f04..cbdf53a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Paystack payment gateway Magento2 extension -**Version:** 3.0.4 (Paystack v2 Inline.js API) +**Version:** 3.0.6 (Paystack v2 Inline.js API) ## Requirements diff --git a/Test/Unit/Controller/Payment/CallbackTest.php b/Test/Unit/Controller/Payment/CallbackTest.php new file mode 100644 index 0000000..0e41620 --- /dev/null +++ b/Test/Unit/Controller/Payment/CallbackTest.php @@ -0,0 +1,178 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Controller\Payment; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Controller\Payment\Callback; +use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Gateway\Exception\ApiException; +use Pstk\Paystack\Model\Ui\ConfigProvider; +use Magento\Framework\App\Action\Context; +use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\App\Response\RedirectInterface; +use Magento\Framework\Controller\Result\RedirectFactory; +use Magento\Framework\Controller\Result\Redirect; +use Magento\Framework\Event\Manager as EventManager; +use Magento\Framework\View\Result\PageFactory; +use Magento\Framework\Message\ManagerInterface as MessageManager; +use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Store\Model\StoreManagerInterface; +use Psr\Log\LoggerInterface; + +class CallbackTest extends TestCase +{ + /** @var Callback */ + private $controller; + + /** @var MockObject|PaystackApiClient */ + private $paystackClient; + + /** @var MockObject|EventManager */ + private $eventManager; + + /** @var MockObject|HttpRequest */ + private $request; + + /** @var MockObject|OrderInterface */ + private $orderInterface; + + /** @var MockObject|MessageManager */ + private $messageManager; + + private function createController(): Callback + { + $this->paystackClient = $this->createMock(PaystackApiClient::class); + $this->eventManager = $this->createMock(EventManager::class); + $this->request = $this->createMock(HttpRequest::class); + $this->orderInterface = $this->createMock(\Magento\Sales\Model\Order::class); + $this->messageManager = $this->createMock(MessageManager::class); + + $redirect = $this->createMock(Redirect::class); + $redirect->method('setUrl')->willReturnSelf(); + + $redirectFactory = $this->createMock(RedirectFactory::class); + $redirectFactory->method('create')->willReturn($redirect); + + $context = $this->createMock(Context::class); + $context->method('getRequest')->willReturn($this->request); + $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class)); + $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class)); + $context->method('getEventManager')->willReturn($this->eventManager); + $context->method('getMessageManager')->willReturn($this->messageManager); + $context->method('getRedirect')->willReturn($this->createMock(RedirectInterface::class)); + $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class)); + $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class)); + $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class)); + $context->method('getResultRedirectFactory')->willReturn($redirectFactory); + $context->method('getResultFactory')->willReturn( + $this->createMock(\Magento\Framework\Controller\ResultFactory::class) + ); + + return new Callback( + $context, + $this->createMock(PageFactory::class), + $this->createMock(OrderRepositoryInterface::class), + $this->orderInterface, + $this->createMock(CheckoutSession::class), + $this->createMock(PaymentHelper::class), + $this->messageManager, + $this->createMock(ConfigProvider::class), + $this->createMock(StoreManagerInterface::class), + $this->eventManager, + $this->request, + $this->createMock(LoggerInterface::class), + $this->paystackClient + ); + } + + public function testSuccessfulCallbackDispatchesEvent(): void + { + $controller = $this->createController(); + + $this->request->method('get') + ->with('reference') + ->willReturn('000000001_suffix'); + + $verifyResponse = (object) [ + 'data' => (object) [ + 'reference' => '000000001_suffix', + 'status' => 'success', + ], + ]; + $this->paystackClient->method('verifyTransaction') + ->with('000000001_suffix') + ->willReturn($verifyResponse); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('000000001'); + $this->orderInterface->method('loadByIncrementId') + ->with('000000001') + ->willReturn($order); + + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->with('paystack_payment_verify_after', ['paystack_order' => $order]); + + $controller->execute(); + } + + public function testMissingReferenceRedirectsToFailure(): void + { + $controller = $this->createController(); + + $this->request->method('get') + ->with('reference') + ->willReturn(null); + + $this->messageManager->expects($this->once()) + ->method('addErrorMessage'); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $controller->execute(); + } + + public function testApiExceptionRedirectsToFailure(): void + { + $controller = $this->createController(); + + $this->request->method('get')->willReturn('bad_ref'); + + $this->paystackClient->method('verifyTransaction') + ->willThrowException(new ApiException('Transaction failed')); + + $this->messageManager->expects($this->once()) + ->method('addErrorMessage'); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $controller->execute(); + } + + public function testOrderNotFoundRedirectsToFailure(): void + { + $controller = $this->createController(); + + $this->request->method('get')->willReturn('000000099'); + + $verifyResponse = (object) [ + 'data' => (object) [ + 'reference' => '000000099', + 'status' => 'success', + ], + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $controller->execute(); + } +} diff --git a/Test/Unit/Controller/Payment/RecreateTest.php b/Test/Unit/Controller/Payment/RecreateTest.php new file mode 100644 index 0000000..7f06c09 --- /dev/null +++ b/Test/Unit/Controller/Payment/RecreateTest.php @@ -0,0 +1,123 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Controller\Payment; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Controller\Payment\Recreate; +use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Model\Ui\ConfigProvider; +use Magento\Framework\App\Action\Context; +use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\App\Response\RedirectInterface; +use Magento\Framework\Controller\Result\RedirectFactory; +use Magento\Framework\Controller\Result\Redirect; +use Magento\Framework\Event\Manager as EventManager; +use Magento\Framework\View\Result\PageFactory; +use Magento\Framework\Message\ManagerInterface as MessageManager; +use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Store\Model\StoreManagerInterface; +use Psr\Log\LoggerInterface; + +class RecreateTest extends TestCase +{ + /** @var MockObject|CheckoutSession */ + private $checkoutSession; + + private function createController(): Recreate + { + $this->checkoutSession = $this->createMock(CheckoutSession::class); + + $redirect = $this->createMock(Redirect::class); + $redirect->method('setUrl')->willReturnSelf(); + + $redirectFactory = $this->createMock(RedirectFactory::class); + $redirectFactory->method('create')->willReturn($redirect); + + $context = $this->createMock(Context::class); + $request = $this->createMock(HttpRequest::class); + $context->method('getRequest')->willReturn($request); + $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class)); + $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class)); + $context->method('getEventManager')->willReturn($this->createMock(EventManager::class)); + $context->method('getMessageManager')->willReturn($this->createMock(MessageManager::class)); + $context->method('getRedirect')->willReturn($this->createMock(RedirectInterface::class)); + $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class)); + $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class)); + $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class)); + $context->method('getResultRedirectFactory')->willReturn($redirectFactory); + $context->method('getResultFactory')->willReturn( + $this->createMock(\Magento\Framework\Controller\ResultFactory::class) + ); + + return new Recreate( + $context, + $this->createMock(PageFactory::class), + $this->createMock(OrderRepositoryInterface::class), + $this->createMock(Order::class), + $this->checkoutSession, + $this->createMock(PaymentHelper::class), + $this->createMock(MessageManager::class), + $this->createMock(ConfigProvider::class), + $this->createMock(StoreManagerInterface::class), + $this->createMock(EventManager::class), + $request, + $this->createMock(LoggerInterface::class), + $this->createMock(PaystackApiClient::class) + ); + } + + public function testCancelsActiveOrderAndRestoresQuote(): void + { + $controller = $this->createController(); + + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(1); + $order->method('getState')->willReturn(Order::STATE_NEW); + + $order->expects($this->once()) + ->method('registerCancellation') + ->with('Payment failed or cancelled') + ->willReturn($order); + $order->expects($this->once())->method('save'); + + $this->checkoutSession->method('getLastRealOrder')->willReturn($order); + $this->checkoutSession->expects($this->once())->method('restoreQuote'); + + $controller->execute(); + } + + public function testAlreadyCancelledOrderIsNotCancelledAgain(): void + { + $controller = $this->createController(); + + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(1); + $order->method('getState')->willReturn(Order::STATE_CANCELED); + + $order->expects($this->never())->method('registerCancellation'); + + $this->checkoutSession->method('getLastRealOrder')->willReturn($order); + $this->checkoutSession->expects($this->once())->method('restoreQuote'); + + $controller->execute(); + } + + public function testNoOrderStillRestoresQuote(): void + { + $controller = $this->createController(); + + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(null); + + $order->expects($this->never())->method('registerCancellation'); + + $this->checkoutSession->method('getLastRealOrder')->willReturn($order); + $this->checkoutSession->expects($this->once())->method('restoreQuote'); + + $controller->execute(); + } +} diff --git a/Test/Unit/Controller/Payment/SetupTest.php b/Test/Unit/Controller/Payment/SetupTest.php new file mode 100644 index 0000000..a4e71a9 --- /dev/null +++ b/Test/Unit/Controller/Payment/SetupTest.php @@ -0,0 +1,206 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Controller\Payment; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Controller\Payment\Setup; +use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Gateway\Exception\ApiException; +use Pstk\Paystack\Model\Payment\Paystack; +use Pstk\Paystack\Model\Ui\ConfigProvider; +use Magento\Framework\App\Action\Context; +use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\App\Response\RedirectInterface; +use Magento\Framework\Controller\Result\Redirect; +use Magento\Framework\Controller\Result\RedirectFactory; +use Magento\Framework\Event\Manager as EventManager; +use Magento\Framework\View\Result\PageFactory; +use Magento\Framework\Message\ManagerInterface as MessageManager; +use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Payment\Model\MethodInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreManagerInterface; +use Psr\Log\LoggerInterface; + +class SetupTest extends TestCase +{ + /** @var MockObject|PaystackApiClient */ + private $paystackClient; + + /** @var MockObject|CheckoutSession */ + private $checkoutSession; + + /** @var MockObject|OrderInterface */ + private $orderInterface; + + /** @var MockObject|PaymentHelper */ + private $paymentHelper; + + /** @var MockObject|StoreManagerInterface */ + private $storeManager; + + /** @var MockObject|OrderRepositoryInterface */ + private $orderRepository; + + /** @var MockObject|MessageManager */ + private $messageManager; + + /** @var MockObject|Redirect */ + private $redirect; + + private function createController(): Setup + { + $this->paystackClient = $this->createMock(PaystackApiClient::class); + $this->checkoutSession = $this->createMock(CheckoutSession::class); + $this->orderInterface = $this->createMock(Order::class); + $this->paymentHelper = $this->createMock(PaymentHelper::class); + $this->storeManager = $this->createMock(StoreManagerInterface::class); + $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->messageManager = $this->createMock(MessageManager::class); + + $this->redirect = $this->createMock(Redirect::class); + $this->redirect->method('setUrl')->willReturnSelf(); + + $redirectFactory = $this->createMock(RedirectFactory::class); + $redirectFactory->method('create')->willReturn($this->redirect); + + $context = $this->createMock(Context::class); + $request = $this->createMock(HttpRequest::class); + $context->method('getRequest')->willReturn($request); + $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class)); + $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class)); + $context->method('getEventManager')->willReturn($this->createMock(EventManager::class)); + $context->method('getMessageManager')->willReturn($this->messageManager); + $context->method('getRedirect')->willReturn($this->createMock(RedirectInterface::class)); + $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class)); + $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class)); + $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class)); + $context->method('getResultRedirectFactory')->willReturn($redirectFactory); + $context->method('getResultFactory')->willReturn( + $this->createMock(\Magento\Framework\Controller\ResultFactory::class) + ); + + return new Setup( + $context, + $this->createMock(PageFactory::class), + $this->orderRepository, + $this->orderInterface, + $this->checkoutSession, + $this->paymentHelper, + $this->messageManager, + $this->createMock(ConfigProvider::class), + $this->storeManager, + $this->createMock(EventManager::class), + $request, + $this->createMock(LoggerInterface::class), + $this->paystackClient + ); + } + + public function testSuccessfulSetupRedirectsToPaystack(): void + { + $controller = $this->createController(); + + $lastOrder = $this->createMock(Order::class); + $lastOrder->method('getIncrementId')->willReturn('000000001'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + + $order = $this->createMock(Order::class); + $order->method('getPayment')->willReturn($payment); + $order->method('getCustomerFirstname')->willReturn('John'); + $order->method('getCustomerLastname')->willReturn('Doe'); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getCustomerEmail')->willReturn('john@example.com'); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getCurrency')->willReturn('NGN'); + + $this->orderInterface->method('loadByIncrementId') + ->with('000000001') + ->willReturn($order); + + $methodInstance = $this->createMock(MethodInterface::class); + $methodInstance->method('getCode')->willReturn(Paystack::CODE); + $this->paymentHelper->method('getMethodInstance') + ->with(Paystack::CODE) + ->willReturn($methodInstance); + + $store = $this->createMock(StoreInterface::class); + $store->method('getBaseUrl')->willReturn('https://example.com/'); + $this->storeManager->method('getStore')->willReturn($store); + + $txResponse = (object) [ + 'data' => (object) [ + 'authorization_url' => 'https://checkout.paystack.com/abc123', + ], + ]; + $this->paystackClient->expects($this->once()) + ->method('initializeTransaction') + ->with($this->callback(function ($params) { + return $params['amount'] === 500000 // kobo + && $params['email'] === 'john@example.com' + && $params['reference'] === '000000001' + && $params['callback_url'] === 'https://example.com/paystack/payment/callback'; + })) + ->willReturn($txResponse); + + $this->redirect->expects($this->once()) + ->method('setUrl') + ->with('https://checkout.paystack.com/abc123'); + + $controller->execute(); + } + + public function testApiExceptionSavesStatusHistory(): void + { + $controller = $this->createController(); + + $lastOrder = $this->createMock(Order::class); + $lastOrder->method('getIncrementId')->willReturn('000000001'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + + $order = $this->createMock(Order::class); + $order->method('getPayment')->willReturn($payment); + $order->method('getStatus')->willReturn('pending'); + $order->method('getCustomerFirstname')->willReturn('John'); + $order->method('getCustomerLastname')->willReturn('Doe'); + $order->method('getGrandTotal')->willReturn(100.00); + $order->method('getCustomerEmail')->willReturn('john@test.com'); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getCurrency')->willReturn('NGN'); + + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $methodInstance = $this->createMock(MethodInterface::class); + $methodInstance->method('getCode')->willReturn(Paystack::CODE); + $this->paymentHelper->method('getMethodInstance')->willReturn($methodInstance); + + $store = $this->createMock(StoreInterface::class); + $store->method('getBaseUrl')->willReturn('https://example.com/'); + $this->storeManager->method('getStore')->willReturn($store); + + $this->paystackClient->method('initializeTransaction') + ->willThrowException(new ApiException('Invalid key')); + + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', 'Invalid key'); + + $this->orderRepository->expects($this->once()) + ->method('save') + ->with($order); + + $controller->execute(); + } +} diff --git a/Test/Unit/Controller/Payment/WebhookTest.php b/Test/Unit/Controller/Payment/WebhookTest.php new file mode 100644 index 0000000..53821f6 --- /dev/null +++ b/Test/Unit/Controller/Payment/WebhookTest.php @@ -0,0 +1,308 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Controller\Payment; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Controller\Payment\Webhook; +use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Gateway\Exception\ApiException; +use Pstk\Paystack\Model\Ui\ConfigProvider; +use Magento\Framework\App\Action\Context; +use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\Controller\ResultFactory; +use Magento\Framework\Controller\Result\Raw; +use Magento\Framework\Event\Manager as EventManager; +use Magento\Framework\View\Result\PageFactory; +use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Api\Data\OrderSearchResultInterface; +use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Store\Model\StoreManagerInterface; +use Psr\Log\LoggerInterface; + +class WebhookTest extends TestCase +{ + /** @var Webhook */ + private $controller; + + /** @var MockObject|PaystackApiClient */ + private $paystackClient; + + /** @var MockObject|EventManager */ + private $eventManager; + + /** @var MockObject|HttpRequest */ + private $request; + + /** @var MockObject|OrderInterface */ + private $orderInterface; + + /** @var MockObject|OrderRepositoryInterface */ + private $orderRepository; + + /** @var MockObject|ConfigProvider */ + private $configProvider; + + /** @var MockObject|Raw */ + private $rawResult; + + /** @var MockObject|LoggerInterface */ + private $logger; + + protected function setUp(): void + { + $this->paystackClient = $this->createMock(PaystackApiClient::class); + $this->eventManager = $this->createMock(EventManager::class); + $this->request = $this->createMock(HttpRequest::class); + $this->orderInterface = $this->createMock(\Magento\Sales\Model\Order::class); + $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->configProvider = $this->createMock(ConfigProvider::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->rawResult = $this->createMock(Raw::class); + $this->rawResult->method('setContents')->willReturnSelf(); + + $resultFactory = $this->createMock(ResultFactory::class); + $resultFactory->method('create') + ->with(ResultFactory::TYPE_RAW) + ->willReturn($this->rawResult); + + $context = $this->createMock(Context::class); + $context->method('getResultFactory')->willReturn($resultFactory); + // getRequest is called by parent constructor + $context->method('getRequest')->willReturn($this->request); + $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class)); + $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class)); + $context->method('getEventManager')->willReturn($this->eventManager); + $context->method('getMessageManager')->willReturn($this->createMock(\Magento\Framework\Message\ManagerInterface::class)); + $context->method('getRedirect')->willReturn($this->createMock(\Magento\Framework\App\Response\RedirectInterface::class)); + $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class)); + $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class)); + $context->method('getUrl')->willReturn($this->createMock(\Magento\Framework\UrlInterface::class)); + $context->method('getResultRedirectFactory')->willReturn( + $this->createMock(\Magento\Framework\Controller\Result\RedirectFactory::class) + ); + + $pageFactory = $this->createMock(PageFactory::class); + $checkoutSession = $this->createMock(CheckoutSession::class); + $paymentHelper = $this->createMock(PaymentHelper::class); + $messageManager = $this->createMock(\Magento\Framework\Message\ManagerInterface::class); + $storeManager = $this->createMock(StoreManagerInterface::class); + + $this->controller = new Webhook( + $context, + $pageFactory, + $this->orderRepository, + $this->orderInterface, + $checkoutSession, + $paymentHelper, + $messageManager, + $this->configProvider, + $storeManager, + $this->eventManager, + $this->request, + $this->logger, + $this->paystackClient + ); + } + + public function testInvalidSignatureReturnsAuthFailed(): void + { + $this->request->method('getContent')->willReturn('{"event":"charge.success"}'); + $this->request->method('getHeader')->with('X-Paystack-Signature')->willReturn('bad_sig'); + + $this->paystackClient->method('validateWebhookSignature')->willReturn(false); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with($this->callback(function ($val) { + return $val === 'auth failed'; + })); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->controller->execute(); + } + + public function testMissingSignatureReturnsAuthFailed(): void + { + $this->request->method('getContent')->willReturn('{"event":"charge.success"}'); + $this->request->method('getHeader')->with('X-Paystack-Signature')->willReturn(''); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with($this->callback(function ($val) { + return $val === 'auth failed'; + })); + + $this->controller->execute(); + } + + public function testChargeSuccessWithValidOrder(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_001', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->with('X-Paystack-Signature')->willReturn('valid_sig'); + + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) [ + 'reference' => 'ORDER_001', + 'status' => 'success', + ], + ]; + $this->paystackClient->method('verifyTransaction') + ->with('ORDER_001') + ->willReturn($verifyResponse); + + $this->configProvider->method('getPublicKey')->willReturn('pk_test_123'); + $this->paystackClient->expects($this->once()) + ->method('logTransactionSuccess') + ->with('ORDER_001', 'pk_test_123'); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getId')->willReturn(1); + $order->method('getIncrementId')->willReturn('ORDER_001'); + $order->method('getStatus')->willReturn('pending'); + + $this->orderInterface->method('loadByIncrementId') + ->with('ORDER_001') + ->willReturn($order); + + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->with('paystack_payment_verify_after', ['paystack_order' => $order]); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('success'); + + $this->controller->execute(); + } + + public function testChargeSuccessWithQuoteIdFallback(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'PSK_ref123', + 'metadata' => ['quoteId' => '55'], + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) [ + 'reference' => 'PSK_ref123', + 'status' => 'success', + 'metadata' => (object) ['quoteId' => '55'], + ], + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + // loadByIncrementId returns empty order (not found by reference) + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + // The webhook uses ObjectManager::getInstance() for the fallback path, + // which cannot be easily unit-tested. Verify the loadByIncrementId was attempted. + $this->orderInterface->expects($this->once())->method('loadByIncrementId'); + + $this->controller->execute(); + } + + public function testInvalidJsonPayloadReturnsInvalidPayload(): void + { + $this->request->method('getContent')->willReturn('not-json'); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with($this->callback(function ($val) { + return $val === 'invalid payload'; + })); + + $this->controller->execute(); + } + + public function testNonChargeSuccessEventIsIgnored(): void + { + $rawBody = json_encode([ + 'event' => 'transfer.success', + 'data' => ['reference' => 'TRF_001'], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->paystackClient->expects($this->never())->method('verifyTransaction'); + + $this->controller->execute(); + } + + public function testChargeSuccessWithNonSuccessStatusIsIgnored(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'failed', + 'reference' => 'ORDER_002', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $this->paystackClient->expects($this->never())->method('verifyTransaction'); + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->controller->execute(); + } + + public function testApiExceptionDuringVerifyReturnsErrorMessage(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_003', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + $this->paystackClient->method('verifyTransaction') + ->willThrowException(new ApiException('API down')); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with($this->callback(function ($val) { + return $val === 'API down'; + })); + + $this->controller->execute(); + } +} diff --git a/Test/Unit/Gateway/PaystackApiClientTest.php b/Test/Unit/Gateway/PaystackApiClientTest.php new file mode 100644 index 0000000..348b658 --- /dev/null +++ b/Test/Unit/Gateway/PaystackApiClientTest.php @@ -0,0 +1,108 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Gateway; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Model\Payment\Paystack; +use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Payment\Model\MethodInterface; + +class PaystackApiClientTest extends TestCase +{ + /** @var PaystackApiClient */ + private $client; + + /** @var MockObject|PaymentHelper */ + private $paymentHelper; + + /** @var MockObject|MethodInterface */ + private $paymentMethod; + + protected function setUp(): void + { + $this->paymentHelper = $this->createMock(PaymentHelper::class); + $this->paymentMethod = $this->createMock(MethodInterface::class); + + $this->paymentHelper->method('getMethodInstance') + ->with(Paystack::CODE) + ->willReturn($this->paymentMethod); + + $this->client = new PaystackApiClient($this->paymentHelper); + } + + public function testValidateWebhookSignatureValid(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + if ($field === 'test_mode') return true; + if ($field === 'test_secret_key') return 'sk_test_secret123'; + return null; + }); + + $rawBody = '{"event":"charge.success","data":{"reference":"abc123"}}'; + $signature = hash_hmac('sha512', $rawBody, 'sk_test_secret123'); + + $this->assertTrue($this->client->validateWebhookSignature($rawBody, $signature)); + } + + public function testValidateWebhookSignatureInvalid(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + if ($field === 'test_mode') return true; + if ($field === 'test_secret_key') return 'sk_test_secret123'; + return null; + }); + + $rawBody = '{"event":"charge.success"}'; + $forgedSignature = hash_hmac('sha512', $rawBody, 'wrong_key'); + + $this->assertFalse($this->client->validateWebhookSignature($rawBody, $forgedSignature)); + } + + public function testValidateWebhookSignatureUsesLiveKeyWhenNotTestMode(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + if ($field === 'test_mode') return false; + if ($field === 'live_secret_key') return 'sk_live_real456'; + return null; + }); + + $rawBody = '{"event":"charge.success"}'; + $signature = hash_hmac('sha512', $rawBody, 'sk_live_real456'); + + $this->assertTrue($this->client->validateWebhookSignature($rawBody, $signature)); + } + + public function testValidateWebhookSignatureEmptySignatureFails(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + if ($field === 'test_mode') return true; + if ($field === 'test_secret_key') return 'sk_test_secret123'; + return null; + }); + + $this->assertFalse($this->client->validateWebhookSignature('body', '')); + } + + public function testValidateWebhookSignatureTampered(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + if ($field === 'test_mode') return true; + if ($field === 'test_secret_key') return 'sk_test_secret123'; + return null; + }); + + $originalBody = '{"event":"charge.success","data":{"amount":10000}}'; + $signature = hash_hmac('sha512', $originalBody, 'sk_test_secret123'); + + $tamperedBody = '{"event":"charge.success","data":{"amount":1}}'; + + $this->assertFalse($this->client->validateWebhookSignature($tamperedBody, $signature)); + } +} diff --git a/Test/Unit/Model/CspPolicyCollectorTest.php b/Test/Unit/Model/CspPolicyCollectorTest.php new file mode 100644 index 0000000..fee1f82 --- /dev/null +++ b/Test/Unit/Model/CspPolicyCollectorTest.php @@ -0,0 +1,59 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Model; + +use PHPUnit\Framework\TestCase; +use Pstk\Paystack\Model\CspPolicyCollector; +use Magento\Csp\Model\Policy\FetchPolicy; + +class CspPolicyCollectorTest extends TestCase +{ + /** @var CspPolicyCollector */ + private $collector; + + protected function setUp(): void + { + $this->collector = new CspPolicyCollector(); + } + + public function testCollectAddsPolicies(): void + { + $policies = $this->collector->collect([]); + + $this->assertCount(3, $policies); + $this->assertContainsOnlyInstancesOf(FetchPolicy::class, $policies); + } + + public function testCollectPreservesDefaultPolicies(): void + { + $existing = [new FetchPolicy('default-src', false, ['example.com'])]; + $policies = $this->collector->collect($existing); + + $this->assertCount(4, $policies); + $this->assertSame($existing[0], $policies[0]); + } + + public function testScriptSrcPolicy(): void + { + $policies = $this->collector->collect(); + $scriptSrc = $policies[0]; + + $this->assertEquals('script-src', $scriptSrc->getId()); + } + + public function testConnectSrcPolicy(): void + { + $policies = $this->collector->collect(); + $connectSrc = $policies[1]; + + $this->assertEquals('connect-src', $connectSrc->getId()); + } + + public function testFrameSrcPolicy(): void + { + $policies = $this->collector->collect(); + $frameSrc = $policies[2]; + + $this->assertEquals('frame-src', $frameSrc->getId()); + } +} diff --git a/Test/Unit/Model/Payment/PaystackTest.php b/Test/Unit/Model/Payment/PaystackTest.php new file mode 100644 index 0000000..723fbe9 --- /dev/null +++ b/Test/Unit/Model/Payment/PaystackTest.php @@ -0,0 +1,353 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Model\Payment; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Model\Payment\Paystack; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Payment\Model\InfoInterface; +use Magento\Quote\Api\Data\CartInterface; + +class PaystackTest extends TestCase +{ + /** @var Paystack */ + private $model; + + /** @var MockObject|ScopeConfigInterface */ + private $scopeConfig; + + protected function setUp(): void + { + $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->model = new Paystack($this->scopeConfig); + } + + // ---- Identity ---- + + public function testGetCodeReturnsPstkPaystack(): void + { + $this->assertEquals('pstk_paystack', $this->model->getCode()); + } + + // ---- Store scope ---- + + public function testSetAndGetStore(): void + { + $this->model->setStore(5); + $this->assertEquals(5, $this->model->getStore()); + } + + // ---- Configuration ---- + + public function testGetConfigDataReadsFromScopeConfig(): void + { + $this->scopeConfig->expects($this->once()) + ->method('getValue') + ->with('payment/pstk_paystack/title', 'store', null) + ->willReturn('Pay with Paystack'); + + $this->assertEquals('Pay with Paystack', $this->model->getConfigData('title')); + } + + public function testGetConfigDataUsesStoreId(): void + { + $this->model->setStore(3); + + $this->scopeConfig->expects($this->once()) + ->method('getValue') + ->with('payment/pstk_paystack/active', 'store', 3) + ->willReturn('1'); + + $this->assertEquals('1', $this->model->getConfigData('active')); + } + + public function testGetConfigDataStoreIdParamOverridesSetStore(): void + { + $this->model->setStore(3); + + $this->scopeConfig->expects($this->once()) + ->method('getValue') + ->with('payment/pstk_paystack/active', 'store', 7) + ->willReturn('0'); + + $this->assertEquals('0', $this->model->getConfigData('active', 7)); + } + + public function testGetTitle(): void + { + $this->scopeConfig->method('getValue')->willReturn('Paystack'); + $this->assertEquals('Paystack', $this->model->getTitle()); + } + + // ---- Availability ---- + + public function testIsActiveWhenConfigEnabled(): void + { + $this->scopeConfig->method('getValue')->willReturn('1'); + $this->assertTrue($this->model->isActive()); + } + + public function testIsActiveWhenConfigDisabled(): void + { + $this->scopeConfig->method('getValue')->willReturn('0'); + $this->assertFalse($this->model->isActive()); + } + + public function testIsAvailableWhenActiveAndCheckoutAllowed(): void + { + $this->scopeConfig->method('getValue') + ->willReturnCallback(function ($path) { + if (str_ends_with($path, '/active')) return '1'; + if (str_ends_with($path, '/can_use_checkout')) return '1'; + return null; + }); + + $quote = $this->createMock(CartInterface::class); + $quote->method('getStoreId')->willReturn(1); + + $this->assertTrue($this->model->isAvailable($quote)); + } + + public function testIsAvailableReturnsFalseWhenInactive(): void + { + $this->scopeConfig->method('getValue')->willReturn('0'); + + $this->assertFalse($this->model->isAvailable()); + } + + public function testCanUseInternalReturnsFalse(): void + { + $this->assertFalse($this->model->canUseInternal()); + } + + public function testCanUseCheckoutDefaultsToTrue(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + $this->assertTrue($this->model->canUseCheckout()); + } + + // ---- Country restrictions ---- + + public function testCanUseForCountryAllowsAllWhenNotRestricted(): void + { + $this->scopeConfig->method('getValue')->willReturn('0'); + $this->assertTrue($this->model->canUseForCountry('NG')); + } + + public function testCanUseForCountryAllowsSpecificCountry(): void + { + $this->scopeConfig->method('getValue') + ->willReturnCallback(function ($path) { + if (str_ends_with($path, '/allowspecific')) return '1'; + if (str_ends_with($path, '/specificcountry')) return 'NG,GH,ZA'; + return null; + }); + + $this->assertTrue($this->model->canUseForCountry('NG')); + } + + public function testCanUseForCountryRejectsUnlistedCountry(): void + { + $this->scopeConfig->method('getValue') + ->willReturnCallback(function ($path) { + if (str_ends_with($path, '/allowspecific')) return '1'; + if (str_ends_with($path, '/specificcountry')) return 'NG,GH'; + return null; + }); + + $this->assertFalse($this->model->canUseForCountry('US')); + } + + public function testCanUseForCountryAllowsEmptyCountry(): void + { + $this->assertTrue($this->model->canUseForCountry('')); + } + + // ---- Currency ---- + + public function testCanUseForCurrencyAcceptsAnyCurrency(): void + { + $this->assertTrue($this->model->canUseForCurrency('NGN')); + $this->assertTrue($this->model->canUseForCurrency('USD')); + $this->assertTrue($this->model->canUseForCurrency('GHS')); + $this->assertTrue($this->model->canUseForCurrency('ZAR')); + $this->assertTrue($this->model->canUseForCurrency('KES')); + } + + // ---- Capability flags ---- + + public function testIsGatewayReturnsTrue(): void + { + $this->assertTrue($this->model->isGateway()); + } + + public function testIsOfflineReturnsFalse(): void + { + $this->assertFalse($this->model->isOffline()); + } + + public function testCanOrderReturnsFalse(): void + { + $this->assertFalse($this->model->canOrder()); + } + + public function testCanAuthorizeReturnsFalse(): void + { + $this->assertFalse($this->model->canAuthorize()); + } + + public function testCanCaptureReturnsFalse(): void + { + $this->assertFalse($this->model->canCapture()); + } + + public function testCanCapturePartialReturnsFalse(): void + { + $this->assertFalse($this->model->canCapturePartial()); + } + + public function testCanCaptureOnceReturnsFalse(): void + { + $this->assertFalse($this->model->canCaptureOnce()); + } + + public function testCanRefundReturnsFalse(): void + { + $this->assertFalse($this->model->canRefund()); + } + + public function testCanRefundPartialPerInvoiceReturnsFalse(): void + { + $this->assertFalse($this->model->canRefundPartialPerInvoice()); + } + + public function testCanVoidReturnsFalse(): void + { + $this->assertFalse($this->model->canVoid()); + } + + public function testCanEditReturnsTrue(): void + { + $this->assertTrue($this->model->canEdit()); + } + + public function testCanFetchTransactionInfoReturnsFalse(): void + { + $this->assertFalse($this->model->canFetchTransactionInfo()); + } + + public function testCanReviewPaymentReturnsFalse(): void + { + $this->assertFalse($this->model->canReviewPayment()); + } + + // ---- Payment actions throw exceptions ---- + + public function testOrderThrowsException(): void + { + $this->expectException(LocalizedException::class); + $payment = $this->createMock(InfoInterface::class); + $this->model->order($payment, 100); + } + + public function testAuthorizeThrowsException(): void + { + $this->expectException(LocalizedException::class); + $payment = $this->createMock(InfoInterface::class); + $this->model->authorize($payment, 100); + } + + public function testCaptureThrowsException(): void + { + $this->expectException(LocalizedException::class); + $payment = $this->createMock(InfoInterface::class); + $this->model->capture($payment, 100); + } + + public function testRefundThrowsException(): void + { + $this->expectException(LocalizedException::class); + $payment = $this->createMock(InfoInterface::class); + $this->model->refund($payment, 50); + } + + public function testVoidThrowsException(): void + { + $this->expectException(LocalizedException::class); + $payment = $this->createMock(InfoInterface::class); + $this->model->void($payment); + } + + public function testAcceptPaymentThrowsException(): void + { + $this->expectException(LocalizedException::class); + $payment = $this->createMock(InfoInterface::class); + $this->model->acceptPayment($payment); + } + + public function testDenyPaymentThrowsException(): void + { + $this->expectException(LocalizedException::class); + $payment = $this->createMock(InfoInterface::class); + $this->model->denyPayment($payment); + } + + // ---- Non-throwing methods ---- + + public function testCancelReturnsThis(): void + { + $payment = $this->createMock(InfoInterface::class); + $this->assertSame($this->model, $this->model->cancel($payment)); + } + + public function testValidateReturnsThis(): void + { + $this->assertSame($this->model, $this->model->validate()); + } + + public function testAssignDataReturnsThis(): void + { + $data = new \Magento\Framework\DataObject(); + $this->assertSame($this->model, $this->model->assignData($data)); + } + + public function testInitializeReturnsThis(): void + { + $stateObject = new \stdClass(); + $this->assertSame($this->model, $this->model->initialize('authorize', $stateObject)); + } + + public function testFetchTransactionInfoReturnsEmptyArray(): void + { + $payment = $this->createMock(InfoInterface::class); + $this->assertEquals([], $this->model->fetchTransactionInfo($payment, 'txn_123')); + } + + // ---- Block types ---- + + public function testGetFormBlockType(): void + { + $this->assertEquals(\Magento\Payment\Block\Form::class, $this->model->getFormBlockType()); + } + + public function testGetInfoBlockType(): void + { + $this->assertEquals(\Magento\Payment\Block\Info::class, $this->model->getInfoBlockType()); + } + + // ---- Info instance ---- + + public function testSetAndGetInfoInstance(): void + { + $info = $this->createMock(InfoInterface::class); + $this->model->setInfoInstance($info); + $this->assertSame($info, $this->model->getInfoInstance()); + } + + public function testGetInfoInstanceReturnsNullByDefault(): void + { + $this->assertNull($this->model->getInfoInstance()); + } +} diff --git a/Test/Unit/Model/PaymentManagementTest.php b/Test/Unit/Model/PaymentManagementTest.php new file mode 100644 index 0000000..c8a77cb --- /dev/null +++ b/Test/Unit/Model/PaymentManagementTest.php @@ -0,0 +1,204 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Model; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Model\PaymentManagement; +use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Gateway\Exception\ApiException; +use Magento\Framework\Event\Manager as EventManager; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Checkout\Model\Session as CheckoutSession; +use Psr\Log\LoggerInterface; + +class PaymentManagementTest extends TestCase +{ + /** @var PaymentManagement */ + private $paymentManagement; + + /** @var MockObject|PaystackApiClient */ + private $paystackClient; + + /** @var MockObject|EventManager */ + private $eventManager; + + /** @var MockObject|OrderInterface */ + private $orderInterface; + + /** @var MockObject|CheckoutSession */ + private $checkoutSession; + + /** @var MockObject|LoggerInterface */ + private $logger; + + protected function setUp(): void + { + $this->paystackClient = $this->createMock(PaystackApiClient::class); + $this->eventManager = $this->createMock(EventManager::class); + $this->orderInterface = $this->createMock(\Magento\Sales\Model\Order::class); + $this->checkoutSession = $this->createMock(CheckoutSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->paymentManagement = new PaymentManagement( + $this->paystackClient, + $this->eventManager, + $this->orderInterface, + $this->checkoutSession, + $this->logger + ); + } + + public function testVerifyPaymentSuccessful(): void + { + $quoteId = '42'; + $reference = 'PSK_abc123_-~-_' . $quoteId; + + $txData = (object) [ + 'status' => 'success', + 'reference' => 'PSK_abc123', + 'metadata' => (object) ['quoteId' => $quoteId], + ]; + $apiResponse = (object) ['data' => $txData]; + + $this->paystackClient->expects($this->once()) + ->method('verifyTransaction') + ->with('PSK_abc123') + ->willReturn($apiResponse); + + $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $lastOrder->method('getIncrementId')->willReturn('000000001'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getQuoteId')->willReturn($quoteId); + $this->orderInterface->method('loadByIncrementId') + ->with('000000001') + ->willReturn($order); + + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->with('paystack_payment_verify_after', ['paystack_order' => $order]); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertTrue($result['status']); + $this->assertEquals('Verification successful', $result['message']); + } + + public function testVerifyPaymentQuoteIdMismatch(): void + { + $reference = 'PSK_abc123_-~-_42'; + + $txData = (object) [ + 'status' => 'success', + 'reference' => 'PSK_abc123', + 'metadata' => (object) ['quoteId' => '99'], + ]; + $apiResponse = (object) ['data' => $txData]; + + $this->paystackClient->method('verifyTransaction')->willReturn($apiResponse); + + $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $lastOrder->method('getIncrementId')->willReturn('000000001'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getQuoteId')->willReturn('42'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + $this->assertStringContainsString("quoteId doesn't match", $result['message']); + } + + public function testVerifyPaymentOrderQuoteIdMismatch(): void + { + $reference = 'PSK_abc123_-~-_42'; + + $txData = (object) [ + 'status' => 'success', + 'reference' => 'PSK_abc123', + 'metadata' => (object) ['quoteId' => '42'], + ]; + $apiResponse = (object) ['data' => $txData]; + + $this->paystackClient->method('verifyTransaction')->willReturn($apiResponse); + + $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $lastOrder->method('getIncrementId')->willReturn('000000001'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getQuoteId')->willReturn('99'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + } + + public function testVerifyPaymentApiExceptionReturnsError(): void + { + $reference = 'bad_ref_-~-_42'; + + $this->paystackClient->method('verifyTransaction') + ->willThrowException(new ApiException('Transaction not found')); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + $this->assertEquals('Transaction not found', $result['message']); + } + + public function testVerifyPaymentNoLastOrderReturnsError(): void + { + $reference = 'PSK_abc123_-~-_42'; + + $txData = (object) [ + 'status' => 'success', + 'reference' => 'PSK_abc123', + 'metadata' => (object) ['quoteId' => '42'], + ]; + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => $txData]); + + $this->checkoutSession->method('getLastRealOrder')->willReturn(null); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + } + + public function testVerifyPaymentNoIncrementIdReturnsError(): void + { + $reference = 'PSK_abc123_-~-_42'; + + $txData = (object) [ + 'status' => 'success', + 'reference' => 'PSK_abc123', + 'metadata' => (object) ['quoteId' => '42'], + ]; + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => $txData]); + + $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $lastOrder->method('getIncrementId')->willReturn(null); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + } +} diff --git a/Test/Unit/Model/Ui/ConfigProviderTest.php b/Test/Unit/Model/Ui/ConfigProviderTest.php new file mode 100644 index 0000000..3ba7c03 --- /dev/null +++ b/Test/Unit/Model/Ui/ConfigProviderTest.php @@ -0,0 +1,185 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Model\Ui; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Model\Ui\ConfigProvider; +use Pstk\Paystack\Model\Payment\Paystack; +use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Payment\Model\MethodInterface; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreManagerInterface; +use Psr\Log\LoggerInterface; + +class ConfigProviderTest extends TestCase +{ + /** @var ConfigProvider */ + private $configProvider; + + /** @var MockObject|MethodInterface */ + private $paymentMethod; + + /** @var MockObject|StoreManagerInterface */ + private $storeManager; + + protected function setUp(): void + { + $this->paymentMethod = $this->createMock(MethodInterface::class); + + $paymentHelper = $this->createMock(PaymentHelper::class); + $paymentHelper->method('getMethodInstance') + ->with(Paystack::CODE) + ->willReturn($this->paymentMethod); + + $this->storeManager = $this->createMock(StoreManagerInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $this->configProvider = new ConfigProvider($paymentHelper, $this->storeManager, $logger); + } + + private function setupStore(string $baseUrl = 'https://shop.example.com/'): void + { + $store = $this->createMock(StoreInterface::class); + $store->method('getBaseUrl')->willReturn($baseUrl); + $this->storeManager->method('getStore')->willReturn($store); + } + + public function testGetConfigReturnsCorrectStructureTestMode(): void + { + $this->setupStore(); + + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + return match ($field) { + 'test_mode' => true, + 'test_public_key' => 'pk_test_abc123', + 'live_public_key' => 'pk_live_xyz', + 'integration_type' => 'inline', + default => null, + }; + }); + + $config = $this->configProvider->getConfig(); + + $this->assertArrayHasKey('payment', $config); + $this->assertArrayHasKey(Paystack::CODE, $config['payment']); + + $pstk = $config['payment'][Paystack::CODE]; + $this->assertEquals('pk_test_abc123', $pstk['public_key']); + $this->assertEquals('inline', $pstk['integration_type']); + $this->assertEquals('https://shop.example.com/rest/', $pstk['api_url']); + $this->assertEquals('https://shop.example.com/paystack/payment/setup', $pstk['integration_type_standard_url']); + $this->assertEquals('https://shop.example.com/paystack/payment/recreate', $pstk['recreate_quote_url']); + } + + public function testGetConfigUsesLiveKeyWhenNotTestMode(): void + { + $this->setupStore(); + + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + return match ($field) { + 'test_mode' => false, + 'live_public_key' => 'pk_live_real', + 'integration_type' => 'redirect', + default => null, + }; + }); + + $config = $this->configProvider->getConfig(); + + $this->assertEquals('pk_live_real', $config['payment'][Paystack::CODE]['public_key']); + $this->assertEquals('redirect', $config['payment'][Paystack::CODE]['integration_type']); + } + + public function testGetConfigDefaultsToInlineIntegrationType(): void + { + $this->setupStore(); + + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + return match ($field) { + 'test_mode' => true, + 'test_public_key' => 'pk_test_123', + 'integration_type' => null, + default => null, + }; + }); + + $config = $this->configProvider->getConfig(); + + $this->assertEquals('inline', $config['payment'][Paystack::CODE]['integration_type']); + } + + public function testGetConfigReturnsEmptyOnException(): void + { + $paymentHelper = $this->createMock(PaymentHelper::class); + $paymentHelper->method('getMethodInstance') + ->willThrowException(new \Exception('Method not found')); + + $configProvider = new ConfigProvider( + $paymentHelper, + $this->createMock(StoreManagerInterface::class), + $this->createMock(LoggerInterface::class) + ); + + $this->assertEquals([], $configProvider->getConfig()); + } + + public function testGetPublicKeyTestMode(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + return match ($field) { + 'test_mode' => true, + 'test_public_key' => 'pk_test_xyz', + default => null, + }; + }); + + $this->assertEquals('pk_test_xyz', $this->configProvider->getPublicKey()); + } + + public function testGetPublicKeyLiveMode(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + return match ($field) { + 'test_mode' => false, + 'live_public_key' => 'pk_live_prod', + default => null, + }; + }); + + $this->assertEquals('pk_live_prod', $this->configProvider->getPublicKey()); + } + + public function testGetSecretKeyArrayTestMode(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + return match ($field) { + 'test_mode' => true, + 'test_secret_key' => 'sk_test_secret', + default => null, + }; + }); + + $this->assertEquals(['test' => 'sk_test_secret'], $this->configProvider->getSecretKeyArray()); + } + + public function testGetSecretKeyArrayLiveMode(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + return match ($field) { + 'test_mode' => false, + 'live_secret_key' => 'sk_live_secret', + default => null, + }; + }); + + $this->assertEquals(['live' => 'sk_live_secret'], $this->configProvider->getSecretKeyArray()); + } +} diff --git a/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php b/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php new file mode 100644 index 0000000..e1009bb --- /dev/null +++ b/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php @@ -0,0 +1,106 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Observer; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Observer\ObserverAfterPaymentVerify; +use Magento\Framework\Event\Observer; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Email\Sender\OrderSender; + +class ObserverAfterPaymentVerifyTest extends TestCase +{ + /** @var ObserverAfterPaymentVerify */ + private $observer; + + /** @var MockObject|OrderSender */ + private $orderSender; + + protected function setUp(): void + { + $this->orderSender = $this->createMock(OrderSender::class); + $this->observer = new ObserverAfterPaymentVerify($this->orderSender); + } + + public function testPendingOrderTransitionsToProcessing(): void + { + $order = $this->createMock(Order::class); + $order->method('getStatus')->willReturn('pending'); + + $order->expects($this->once()) + ->method('setState') + ->with(Order::STATE_PROCESSING) + ->willReturn($order); + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with( + Order::STATE_PROCESSING, + $this->callback(fn($msg) => str_contains((string)$msg, 'Paystack Payment Verified')), + true + ) + ->willReturn($order); + $order->expects($this->once()) + ->method('setCanSendNewEmailFlag') + ->with(true) + ->willReturn($order); + $order->expects($this->once()) + ->method('setCustomerNoteNotify') + ->with(true) + ->willReturn($order); + $order->expects($this->once())->method('save'); + + $this->orderSender->expects($this->once()) + ->method('send') + ->with($order, true); + + $eventObserver = $this->createMock(Observer::class); + $eventObserver->method('getPaystackOrder')->willReturn($order); + + $this->observer->execute($eventObserver); + } + + public function testNonPendingOrderIsNotUpdated(): void + { + $order = $this->createMock(Order::class); + $order->method('getStatus')->willReturn('processing'); + + $order->expects($this->never())->method('setState'); + $order->expects($this->never())->method('save'); + + $eventObserver = $this->createMock(Observer::class); + $eventObserver->method('getPaystackOrder')->willReturn($order); + + $this->observer->execute($eventObserver); + } + + public function testEmailSendingFailureDoesNotAffectOrderStatus(): void + { + $order = $this->createMock(Order::class); + $order->method('getStatus')->willReturn('pending'); + $order->method('setState')->willReturn($order); + $order->method('addStatusToHistory')->willReturn($order); + $order->method('setCanSendNewEmailFlag')->willReturn($order); + $order->method('setCustomerNoteNotify')->willReturn($order); + + $order->expects($this->once())->method('save'); + + $this->orderSender->method('send') + ->willThrowException(new \Exception('SMTP failure')); + + $eventObserver = $this->createMock(Observer::class); + $eventObserver->method('getPaystackOrder')->willReturn($order); + + // Should not throw + $this->observer->execute($eventObserver); + } + + public function testNullOrderDoesNotCrash(): void + { + $eventObserver = $this->createMock(Observer::class); + $eventObserver->method('getPaystackOrder')->willReturn(null); + + // Should not throw + $this->observer->execute($eventObserver); + } +} diff --git a/Test/Unit/Observer/ObserverBeforeSalesOrderPlaceTest.php b/Test/Unit/Observer/ObserverBeforeSalesOrderPlaceTest.php new file mode 100644 index 0000000..2d13c99 --- /dev/null +++ b/Test/Unit/Observer/ObserverBeforeSalesOrderPlaceTest.php @@ -0,0 +1,79 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Observer; + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Pstk\Paystack\Observer\ObserverBeforeSalesOrderPlace; +use Pstk\Paystack\Model\Payment\Paystack; +use Magento\Framework\Event\Observer; +use Magento\Framework\Event; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; + +class ObserverBeforeSalesOrderPlaceTest extends TestCase +{ + /** @var ObserverBeforeSalesOrderPlace */ + private $observer; + + protected function setUp(): void + { + $this->observer = new ObserverBeforeSalesOrderPlace(); + } + + public function testPaystackOrderSuppressesEmail(): void + { + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + + $order = $this->createMock(Order::class); + $order->method('getPayment')->willReturn($payment); + $order->expects($this->once()) + ->method('setCanSendNewEmailFlag') + ->with(false) + ->willReturn($order); + $order->expects($this->once()) + ->method('setCustomerNoteNotify') + ->with(false); + + $event = $this->createMock(Event::class); + $event->method('getOrder')->willReturn($order); + + $eventObserver = $this->createMock(Observer::class); + $eventObserver->method('getEvent')->willReturn($event); + + $this->observer->execute($eventObserver); + } + + public function testNonPaystackOrderDoesNotSuppressEmail(): void + { + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn('checkmo'); + + $order = $this->createMock(Order::class); + $order->method('getPayment')->willReturn($payment); + $order->expects($this->never())->method('setCanSendNewEmailFlag'); + + $event = $this->createMock(Event::class); + $event->method('getOrder')->willReturn($order); + + $eventObserver = $this->createMock(Observer::class); + $eventObserver->method('getEvent')->willReturn($event); + + $this->observer->execute($eventObserver); + } + + public function testNullPaymentDoesNotCrash(): void + { + $order = $this->createMock(Order::class); + $order->method('getPayment')->willReturn(null); + + $event = $this->createMock(Event::class); + $event->method('getOrder')->willReturn($order); + + $eventObserver = $this->createMock(Observer::class); + $eventObserver->method('getEvent')->willReturn($event); + + $this->observer->execute($eventObserver); + } +} diff --git a/Test/Unit/Plugin/CsrfValidatorSkipTest.php b/Test/Unit/Plugin/CsrfValidatorSkipTest.php new file mode 100644 index 0000000..2bca51b --- /dev/null +++ b/Test/Unit/Plugin/CsrfValidatorSkipTest.php @@ -0,0 +1,76 @@ +<?php + +namespace Pstk\Paystack\Test\Unit\Plugin; + +use PHPUnit\Framework\TestCase; +use Pstk\Paystack\Plugin\CsrfValidatorSkip; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\App\ActionInterface; + +class CsrfValidatorSkipTest extends TestCase +{ + /** @var CsrfValidatorSkip */ + private $plugin; + + protected function setUp(): void + { + $this->plugin = new CsrfValidatorSkip(); + } + + public function testWebhookActionSkipsCsrfValidation(): void + { + $request = $this->createMock(RequestInterface::class); + $request->method('getModuleName')->willReturn('paystack'); + $request->method('getActionName')->willReturn('webhook'); + + $action = $this->createMock(ActionInterface::class); + $subject = new \stdClass(); + + $proceedCalled = false; + $proceed = function () use (&$proceedCalled) { + $proceedCalled = true; + }; + + $this->plugin->aroundValidate($subject, $proceed, $request, $action); + + $this->assertFalse($proceedCalled, 'CSRF validation should be skipped for webhook'); + } + + public function testNonWebhookActionProceedsWithCsrfValidation(): void + { + $request = $this->createMock(RequestInterface::class); + $request->method('getModuleName')->willReturn('checkout'); + $request->method('getActionName')->willReturn('index'); + + $action = $this->createMock(ActionInterface::class); + $subject = new \stdClass(); + + $proceedCalled = false; + $proceed = function () use (&$proceedCalled) { + $proceedCalled = true; + }; + + $this->plugin->aroundValidate($subject, $proceed, $request, $action); + + $this->assertTrue($proceedCalled, 'CSRF validation should proceed for non-webhook actions'); + } + + public function testPaystackNonWebhookActionProceedsNormally(): void + { + $request = $this->createMock(RequestInterface::class); + $request->method('getModuleName')->willReturn('paystack'); + $request->method('getActionName')->willReturn('callback'); + + $action = $this->createMock(ActionInterface::class); + $subject = new \stdClass(); + + $proceedCalled = false; + $proceed = function () use (&$proceedCalled) { + $proceedCalled = true; + }; + + $this->plugin->aroundValidate($subject, $proceed, $request, $action); + + $this->assertTrue($proceedCalled, 'CSRF validation should proceed for paystack/callback'); + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d5e5c35 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.net/10.5/phpunit.xsd" + bootstrap="vendor/autoload.php" + colors="true" + cacheDirectory=".phpunit.cache"> + <testsuites> + <testsuite name="Paystack Unit Tests"> + <directory>Test/Unit</directory> + </testsuite> + </testsuites> + <source> + <include> + <directory>.</directory> + </include> + <exclude> + <directory>Test</directory> + <directory>dev</directory> + <directory>docs</directory> + <directory>vendor</directory> + </exclude> + </source> +</phpunit> From 96ba53e66444c334d307f00fc51212f2d7f15798 Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Fri, 13 Mar 2026 13:08:34 +0200 Subject: [PATCH 05/14] fix: match AbstractMethod contract in getInfoInstance() and add admin area guard to prevent EE admin crash --- Model/Payment/Paystack.php | 40 ++++++++++++++++++++++++ Test/Unit/Model/Payment/PaystackTest.php | 33 +++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/Model/Payment/Paystack.php b/Model/Payment/Paystack.php index fe4062f..5e59139 100644 --- a/Model/Payment/Paystack.php +++ b/Model/Payment/Paystack.php @@ -22,7 +22,9 @@ namespace Pstk\Paystack\Model\Payment; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\State; use Magento\Framework\DataObject; +use Magento\Framework\Exception\LocalizedException; use Magento\Payment\Model\InfoInterface; use Magento\Payment\Model\MethodInterface; use Magento\Quote\Api\Data\CartInterface; @@ -35,6 +37,11 @@ * Adobe Commerce EE plugins on AbstractMethod cannot be inherited by this class. * This prevents EE-specific interceptors with frontend-only dependencies from * crashing admin pages such as the order-creation customer-selection grid. + * + * IMPORTANT: getInfoInstance() must throw LocalizedException when no instance + * is set, matching AbstractMethod's contract. EE plugins on MethodInterface + * rely on this exception (not a null return) to gracefully skip methods that + * have no associated payment info during admin page rendering. */ class Paystack extends DataObject implements MethodInterface { @@ -55,11 +62,16 @@ class Paystack extends DataObject implements MethodInterface /** @var ScopeConfigInterface */ private $scopeConfig; + /** @var State */ + private $appState; + public function __construct( ScopeConfigInterface $scopeConfig, + State $appState, array $data = [] ) { $this->scopeConfig = $scopeConfig; + $this->appState = $appState; parent::__construct($data); } @@ -116,6 +128,9 @@ public function isActive($storeId = null) public function isAvailable(?CartInterface $quote = null) { + if ($this->isAdminArea()) { + return false; + } $storeId = $quote ? $quote->getStoreId() : null; return $this->isActive($storeId) && $this->canUseCheckout(); } @@ -244,6 +259,11 @@ public function getInfoBlockType() public function getInfoInstance() { + if (!$this->infoInstance instanceof InfoInterface) { + throw new LocalizedException( + __('We cannot retrieve the payment information object instance.') + ); + } return $this->infoInstance; } @@ -335,4 +355,24 @@ public function denyPayment(InfoInterface $payment) __('Deny payment action is not supported by Paystack.') ); } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Detect whether we are running in the adminhtml area. + * + * Used as a defence-in-depth guard so that even if canUseInternal() is + * somehow bypassed, Paystack never surfaces as available in admin context. + */ + private function isAdminArea(): bool + { + try { + return $this->appState->getAreaCode() === \Magento\Framework\App\Area::AREA_ADMINHTML; + } catch (\Magento\Framework\Exception\LocalizedException $e) { + // Area code not set yet (e.g. during setup:di:compile) — safe default. + return false; + } + } } diff --git a/Test/Unit/Model/Payment/PaystackTest.php b/Test/Unit/Model/Payment/PaystackTest.php index 723fbe9..e6b649f 100644 --- a/Test/Unit/Model/Payment/PaystackTest.php +++ b/Test/Unit/Model/Payment/PaystackTest.php @@ -6,6 +6,8 @@ use PHPUnit\Framework\MockObject\MockObject; use Pstk\Paystack\Model\Payment\Paystack; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\State; +use Magento\Framework\App\Area; use Magento\Framework\Exception\LocalizedException; use Magento\Payment\Model\InfoInterface; use Magento\Quote\Api\Data\CartInterface; @@ -18,10 +20,26 @@ class PaystackTest extends TestCase /** @var MockObject|ScopeConfigInterface */ private $scopeConfig; + /** @var MockObject|State */ + private $appState; + protected function setUp(): void { $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); - $this->model = new Paystack($this->scopeConfig); + $this->appState = $this->createMock(State::class); + // Default to frontend area for most tests + $this->appState->method('getAreaCode')->willReturn(Area::AREA_FRONTEND); + $this->model = new Paystack($this->scopeConfig, $this->appState); + } + + /** + * Helper: create a Paystack instance in admin area context. + */ + private function createAdminModel(): Paystack + { + $adminState = $this->createMock(State::class); + $adminState->method('getAreaCode')->willReturn(Area::AREA_ADMINHTML); + return new Paystack($this->scopeConfig, $adminState); } // ---- Identity ---- @@ -117,6 +135,14 @@ public function testIsAvailableReturnsFalseWhenInactive(): void $this->assertFalse($this->model->isAvailable()); } + public function testIsAvailableReturnsFalseInAdminArea(): void + { + $this->scopeConfig->method('getValue')->willReturn('1'); + + $adminModel = $this->createAdminModel(); + $this->assertFalse($adminModel->isAvailable()); + } + public function testCanUseInternalReturnsFalse(): void { $this->assertFalse($this->model->canUseInternal()); @@ -346,8 +372,9 @@ public function testSetAndGetInfoInstance(): void $this->assertSame($info, $this->model->getInfoInstance()); } - public function testGetInfoInstanceReturnsNullByDefault(): void + public function testGetInfoInstanceThrowsWhenNotSet(): void { - $this->assertNull($this->model->getInfoInstance()); + $this->expectException(LocalizedException::class); + $this->model->getInfoInstance(); } } From cf8184f7c88767f78ddb25c13f03c19c64e0d52c Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Tue, 7 Apr 2026 15:26:05 +0200 Subject: [PATCH 06/14] Added test and fixes --- .DS_Store | Bin 0 -> 8196 bytes .gitignore | 5 + Model/Payment/Paystack.php | 26 +- Model/PaymentManagement.php | 2 +- Model/Ui/ConfigProvider.php | 2 +- README.md | 276 +++++++++--------- .../ActionGroup/LoginToAdminActionGroup.xml | 20 ++ Test/Mftf/README.md | 26 ++ .../PaystackPaymentConfigAvailableTest.xml | 23 ++ dev/Dockerfile | 2 +- dev/entrypoint.sh | 1 - etc/adminhtml/di.xml | 10 + .../method-renderer/pstk_paystack-method.js | 249 +++++++++------- 13 files changed, 393 insertions(+), 249 deletions(-) create mode 100644 .DS_Store create mode 100644 Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml create mode 100644 Test/Mftf/README.md create mode 100644 Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml create mode 100644 etc/adminhtml/di.xml diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1dbc8367ec934d4f96039f72e8b097258d93834c GIT binary patch literal 8196 zcmeHMPjAyO6n}2JEoB;12&5r_6o~_uZL}L2OkBFI6`BMCtz#Sjji!y-urv-${|Kg{ z-r+OsE9|@zN4^2yg%kYjWL2ECXeR`kJ=xE(f6w;&`8}u3h=`SXjRMgk5gEur=H^jM zNTOflb14<I+=gV(CtkN)Zs93+4yzk91DXNNfM!55pc(i#7{EJQBxA~ZKkU_|W<WD= zDH#ywgNZC;F_NQD%A*5?yaGUG&@2<`^2a}<DH<S)ksO5*El4a>g$z|$bj4uN-GPPb z=r2b2Q7A)oVsd7T6V1$`8w!iA9?X<*V#QGEQZt|#IM0B%yQgWBd~&v3<^H~jo|RuR zNwqB1s7GCD(F(n%fH-*xjgy4N43)?Qu1<$4lHExluakwb9iSVujCmT+vdBjL6Iy-D z7*I<cgD@NW99!Q4=Hb)^ngeZ9XWBa1m&ELoG$*h7XF~HDZP9bup*`BAZCLvfwQW2< z!Q5?Xs4Gr26WUjx4Hm&^2hznKR;}t_XNZntR;Iy?P5lH)Fds){B!hJdR9xy9obAU( z8{@1&;78wXPA%Vcf^I(lgQe1$nb|pG&X_meR$F{f?bf=zpj7L;7EgPeyVhB~X6-xn z;2?Kni+kOg<Jldl;52MN-n??W1|O7o&uauyw;FaBStEOpyLEI_c=WKaWUdu9hD+wr z#%g}ae6Y4Y9A=H1cke&mJvemR9v9n&#G*F<ErieG@+)d%gWP7JTIX$l+#Bm_^Kv;d z);Qf6e*19pUC=z%ZS%$-`~BARvHR7I=W<VMwOD5wo7JVdx((9(<J&a{hp7Q)(*W{g z0xj%T(NE!ITU3FgRjHCd3m1)}XXqK6OE`_IBBeXVkp^7ZibAIk`~+8IS7XjIOE2KE zJ!&FECmaO|_8!7AfQ5$$X<yhVpK9W0Kua4fP0T1lfzzsN5o_+rkXWWQgi{`Q9a$a- zswIL?<tWG^94iQ&GHkC*3Pq_Aw<U)Vw526tl^wYojttHfthYseVn*zVV&8(~t&0`= zbfDUk(8T^Ini(7!wqQfWiNIHE%1|FX!$VY@*hOVLY`;#Lfr~JZ4nd3akd^7*|1UC> z#%KmK1OF)lEK{zQOIZBbFRP>-;;wBXze5&D;6|a8Dk$W298zA#A;<qPMBM~b=oBM4 Y3ME>Q{{Dvmd?C>9f62WrE-wSW0eOGWZvX%Q literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index aba8ab0..0af297a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ composer.phar # Environment .env dev/.env + +# Build artifact (run build-adobe-zip.sh to create) +*.zip +build-adobe-zip.log +docs diff --git a/Model/Payment/Paystack.php b/Model/Payment/Paystack.php index 0a72f67..447ce7b 100644 --- a/Model/Payment/Paystack.php +++ b/Model/Payment/Paystack.php @@ -22,21 +22,39 @@ namespace Pstk\Paystack\Model\Payment; /** - * Paystack main payment method model - * + * Paystack main payment method model. + * Intentionally not available in admin (Create Order) to avoid any impact on + * admin order creation UI or MFTF tests. + * * @author Olayode Ezekiel <kielsoft@gmail.com> */ class Paystack extends \Magento\Payment\Model\Method\AbstractMethod { const CODE = 'pstk_paystack'; - + protected $_code = self::CODE; protected $_isOffline = true; + /** + * Not available for admin (internal) order creation — frontend checkout only. + */ + protected $_canUseInternal = false; + + /** + * Check whether the method is available so that it never breaks admin or checkout. + * Returns false on any exception to avoid breaking payment method list or layout. + * + * @param \Magento\Quote\Api\Data\CartInterface|null $quote + * @return bool + */ public function isAvailable( ?\Magento\Quote\Api\Data\CartInterface $quote = null ) { - return parent::isAvailable($quote); + try { + return parent::isAvailable($quote); + } catch (\Throwable $e) { + return false; + } } } diff --git a/Model/PaymentManagement.php b/Model/PaymentManagement.php index 1210d62..2faedc6 100644 --- a/Model/PaymentManagement.php +++ b/Model/PaymentManagement.php @@ -103,7 +103,7 @@ public function verifyPayment($reference) ]); } $this->logger->warning('Paystack: quoteId mismatch — order not updated'); - } catch (Exception $e) { + } catch (\Throwable $e) { $this->logger->error('Paystack: verifyPayment exception', ['error' => $e->getMessage()]); return json_encode([ 'status' => false, diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php index 6b3c155..7de2650 100644 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -64,7 +64,7 @@ public function getConfig() ] ] ]; - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->logger->error('Paystack ConfigProvider: ' . $e->getMessage()); return []; } diff --git a/README.md b/README.md index 6e04f04..0847085 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,138 @@ -[![Latest Version on Packagist][ico-version]][link-packagist] -[![Software License][ico-license]](LICENSE) -[![Total Downloads][ico-downloads]][link-downloads] - -## Paystack Magento 2 Module - -Paystack payment gateway Magento2 extension - -**Version:** 3.0.4 (Paystack v2 Inline.js API) - -## Requirements - -- Magento 2.4.x -- PHP 8.2+ - -## Installation - -### Composer (Recommended) - -Go to your Magento2 root folder and run: - -```bash -composer require pstk/paystack-magento2-module -php bin/magento module:enable Pstk_Paystack -php bin/magento setup:upgrade -php bin/magento setup:di:compile -php bin/magento cache:flush -``` - -### Manual Installation - -Copy all files to `app/code/Pstk/Paystack/` in your Magento installation, then run: - -```bash -php bin/magento module:enable Pstk_Paystack -php bin/magento setup:upgrade -php bin/magento setup:di:compile -php bin/magento cache:flush -``` - -## Configuration - -To configure the plugin in *Magento Admin*: -1. Go to **Stores > Configuration > Sales > Payment Methods**. -2. Find **Paystack** and configure: - - **Enabled**: Yes/No - - **Title**: What customers see at checkout - - **Integration Type**: Inline (Popup) or Redirect - - **Test Mode**: Enable for sandbox testing - - **Test/Live Secret Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer) - - **Test/Live Public Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer) -3. Click **Save Config**. - -### Webhook Setup - -For reliable payment confirmation (especially for the redirect flow), set up a webhook in your Paystack dashboard: - -1. Go to **Settings > API Keys & Webhooks** on your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer) -2. Set the Webhook URL to: `https://yourdomain.com/paystack/payment/webhook` -3. The module handles `charge.success` events and automatically updates order status - -## Development Environment - -A Docker-based development environment is included in the `dev/` directory for contributors and testing. - -### Prerequisites - -- [Docker](https://www.docker.com/) (or [Rancher Desktop](https://rancherdesktop.io/) with `dockerd` runtime) -- A [Paystack test account](https://dashboard.paystack.com/#/signup) - -### Quick Start - -```bash -cd dev -cp .env.example .env # Add your Paystack test keys -docker compose up -d # First run builds the image (~5 min) and installs Magento (~3 min) -bash setup.sh # Enables module, creates test products, configures everything -``` - -Once complete you'll see: - -``` -============================================ - Setup complete! - - Storefront: http://localhost:8080 - Admin panel: http://localhost:8080/admin - Admin login: admin / Admin12345! - - Test card: 4084 0840 8408 4081 - Expiry: 12/30 - CVV: 408 - PIN: 0000 - OTP: 123456 -============================================ -``` - -### What's Included - -- **Magento 2.4.8-p3** via [Mage-OS](https://mage-os.org/) public mirror (no Adobe marketplace auth needed) -- **OpenSearch 2.19.1** + **MariaDB 10.6** -- **5 test products** with images and a configured homepage -- **Paystack payment** pre-configured in test mode (inline popup) -- Container names: `paystack-magento`, `paystack-db`, `paystack-search` - -### Tear Down - -```bash -cd dev -docker compose down # Stop containers (preserves data) -docker compose down -v # Stop containers and delete all data -``` - -## Documentation - -* [Paystack Documentation](https://developers.paystack.co/v2.0/docs/) -* [Paystack Helpdesk](https://paystack.com/help) - -## Support - -For bug reports and feature requests directly related to this plugin, please use the [issue tracker](https://github.com/PaystackHQ/plugin-magento-2/issues). - -For general support or questions about your Paystack account, you can reach out by sending a message from [our website](https://paystack.com/contact). - -## Community - -If you are a developer, please join our Developer Community on [Slack](https://slack.paystack.com). - -## Contributing to the Magento 2 plugin - -If you have a patch or have stumbled upon an issue with the Magento 2 plugin, you can contribute this back to the code. Please read our [contributor guidelines](https://github.com/PaystackHQ/plugin-magento-2/blob/master/CONTRIBUTING.md) for more information how you can do this. - -[ico-version]: https://img.shields.io/packagist/v/pstk/paystack-magento2-module.svg?style=flat-square -[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square -[ico-downloads]: https://img.shields.io/packagist/dt/pstk/paystack-magento2-module.svg?style=flat-square - -[link-packagist]: https://packagist.org/packages/pstk/paystack-magento2-module -[link-downloads]: https://packagist.org/packages/pstk/paystack-magento2-module +[![Latest Version on Packagist][ico-version]][link-packagist] +[![Software License][ico-license]](LICENSE) +[![Total Downloads][ico-downloads]][link-downloads] + +## Paystack Magento 2 Module + +Paystack payment gateway Magento2 extension + +**Version:** 3.0.6 (Paystack v2 Inline.js API) + +## Requirements + +- Magento 2.4.x +- PHP 8.2+ + +## Installation + +### Composer (Recommended) + +Go to your Magento2 root folder and run: + +```bash +composer require pstk/paystack-magento2-module +php bin/magento module:enable Pstk_Paystack +php bin/magento setup:upgrade +php bin/magento setup:di:compile +php bin/magento cache:flush +``` + +### Manual Installation + +Copy all files to `app/code/Pstk/Paystack/` in your Magento installation, then run: + +```bash +php bin/magento module:enable Pstk_Paystack +php bin/magento setup:upgrade +php bin/magento setup:di:compile +php bin/magento cache:flush +``` + +## Configuration + +To configure the plugin in *Magento Admin*: +1. Go to **Stores > Configuration > Sales > Payment Methods**. +2. Find **Paystack** and configure: + - **Enabled**: Yes/No + - **Title**: What customers see at checkout + - **Integration Type**: Inline (Popup) or Redirect + - **Test Mode**: Enable for sandbox testing + - **Test/Live Secret Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer) + - **Test/Live Public Key**: Get from your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer) +3. Click **Save Config**. + +### Webhook Setup + +For reliable payment confirmation (especially for the redirect flow), set up a webhook in your Paystack dashboard: + +1. Go to **Settings > API Keys & Webhooks** on your [Paystack dashboard](https://dashboard.paystack.com/#/settings/developer) +2. Set the Webhook URL to: `https://yourdomain.com/paystack/payment/webhook` +3. The module handles `charge.success` events and automatically updates order status + +## Development Environment + +A Docker-based development environment is included in the `dev/` directory for contributors and testing. + +### Prerequisites + +- [Docker](https://www.docker.com/) (or [Rancher Desktop](https://rancherdesktop.io/) with `dockerd` runtime) +- A [Paystack test account](https://dashboard.paystack.com/#/signup) + +### Quick Start + +```bash +cd dev +cp .env.example .env # Add your Paystack test keys +docker compose up -d # First run builds the image (~5 min) and installs Magento (~3 min) +bash setup.sh # Enables module, creates test products, configures everything +``` + +Once complete you'll see: + +``` +============================================ + Setup complete! + + Storefront: http://localhost:8080 + Admin panel: http://localhost:8080/admin + Admin login: admin / Admin12345! + + Test card: 4084 0840 8408 4081 + Expiry: 12/30 + CVV: 408 + PIN: 0000 + OTP: 123456 +============================================ +``` + +### What's Included + +- **Magento 2.4.8-p3** via [Mage-OS](https://mage-os.org/) public mirror (no Adobe marketplace auth needed) +- **OpenSearch 2.19.1** + **MariaDB 10.6** +- **5 test products** with images and a configured homepage +- **Paystack payment** pre-configured in test mode (inline popup) +- Container names: `paystack-magento`, `paystack-db`, `paystack-search` + +### Tear Down + +```bash +cd dev +docker compose down # Stop containers (preserves data) +docker compose down -v # Stop containers and delete all data +``` + +## Documentation + +* [Paystack Documentation](https://developers.paystack.co/v2.0/docs/) +* [Paystack Helpdesk](https://paystack.com/help) + +## Support + +For bug reports and feature requests directly related to this plugin, please use the [issue tracker](https://github.com/PaystackHQ/plugin-magento-2/issues). + +For general support or questions about your Paystack account, you can reach out by sending a message from [our website](https://paystack.com/contact). + +## Community + +If you are a developer, please join our Developer Community on [Slack](https://slack.paystack.com). + +## Contributing to the Magento 2 plugin + +If you have a patch or have stumbled upon an issue with the Magento 2 plugin, you can contribute this back to the code. Please read our [contributor guidelines](https://github.com/PaystackHQ/plugin-magento-2/blob/master/CONTRIBUTING.md) for more information how you can do this. + +[ico-version]: https://img.shields.io/packagist/v/pstk/paystack-magento2-module.svg?style=flat-square +[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square +[ico-downloads]: https://img.shields.io/packagist/dt/pstk/paystack-magento2-module.svg?style=flat-square + +[link-packagist]: https://packagist.org/packages/pstk/paystack-magento2-module +[link-downloads]: https://packagist.org/packages/pstk/paystack-magento2-module diff --git a/Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml b/Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml new file mode 100644 index 0000000..07c2c88 --- /dev/null +++ b/Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Self-contained admin login so the vendor test does not depend on Magento_Backend + action groups during MFTF generation (avoids "failed to generate" in Adobe pipeline). +--> +<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> + <actionGroup name="LoginToAdminActionGroup"> + <arguments> + <argument name="adminUser" type="string" defaultValue="admin"/> + <argument name="adminPassword" type="string" defaultValue=""/> + </arguments> + <amOnPage stepKey="navigateToAdmin" url="admin"/> + <waitForPageLoad stepKey="waitForAdminPage" time="30"/> + <fillField stepKey="fillUsername" selector="#username" userInput="{{adminUser}}"/> + <fillField stepKey="fillPassword" selector="#login" userInput="{{adminPassword}}"/> + <click stepKey="clickLogin" selector=".actions .action-primary"/> + <waitForPageLoad stepKey="waitForDashboard" time="30"/> + </actionGroup> +</actionGroups> diff --git a/Test/Mftf/README.md b/Test/Mftf/README.md new file mode 100644 index 0000000..edbdfea --- /dev/null +++ b/Test/Mftf/README.md @@ -0,0 +1,26 @@ +# MFTF tests for Pstk_Paystack + +This module provides **vendor-supplied** MFTF tests for the Paystack payment extension. The test and action group are self-contained (no refs to Magento_Backend) so generation succeeds in the Adobe Commerce Marketplace pipeline. + +## Running + +From the Magento root (with the extension installed), ensure `MAGENTO_ADMIN_PASSWORD` is set (e.g. in `.env` or `.credentials`), then: + +```bash +vendor/bin/mftf generate:tests +vendor/bin/mftf run:test PaystackPaymentConfigAvailableTest +``` + +Or run by group: + +```bash +vendor/bin/mftf run:group Paystack +``` + +## Tests + +- **PaystackPaymentConfigAvailableTest** – Logs in to admin (using our LoginToAdminActionGroup), opens Stores → Configuration → Sales → Payment Methods, and asserts that “Paystack” is visible. + +## Action groups + +- **LoginToAdminActionGroup** – Self-contained admin login (navigate to admin, fill username/password, click login). Used so the vendor test does not depend on Magento_Backend during MFTF generation. diff --git a/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml new file mode 100644 index 0000000..3434eed --- /dev/null +++ b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="PaystackPaymentConfigAvailableTest"> + <annotations> + <title value="Paystack payment method is available in admin configuration"/> + <description value="Verify Paystack appears in Stores > Configuration > Sales > Payment Methods when the extension is installed."/> + <group value="Paystack"/> + </annotations> + <before> + <actionGroup stepKey="loginAsAdmin" ref="LoginToAdminActionGroup"> + <argument name="adminUser" value="admin"/> + <argument name="adminPassword" value="{{_ENV.MAGENTO_ADMIN_PASSWORD}}"/> + </actionGroup> + </before> + <amOnPage stepKey="goToPaymentConfig" url="admin/system_config/edit/section/payment"/> + <waitForPageLoad stepKey="waitForPaymentConfig" time="60"/> + <see stepKey="seePaystackSection" userInput="Paystack"/> + <after> + <amOnPage stepKey="logout" url="admin/admin/auth/logout/"/> + </after> + </test> +</tests> diff --git a/dev/Dockerfile b/dev/Dockerfile index c7280f5..2c76eec 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.3-apache +FROM php:8.4-apache # Install system dependencies RUN apt-get update && apt-get install -y \ diff --git a/dev/entrypoint.sh b/dev/entrypoint.sh index 71f57c6..88ca037 100644 --- a/dev/entrypoint.sh +++ b/dev/entrypoint.sh @@ -26,7 +26,6 @@ if [ ! -f /var/www/html/app/etc/env.php ]; then --backend-frontname=admin \ --language=en_US \ --currency=NGN \ - --allowed-currencies=NGN \ --timezone=Africa/Lagos \ --use-rewrites=1 \ --cleanup-database diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml new file mode 100644 index 0000000..898d75e --- /dev/null +++ b/etc/adminhtml/di.xml @@ -0,0 +1,10 @@ +<?xml version="1.0"?> +<!-- + Intentionally empty: this module does not register any plugins, preferences, + or type configuration in admin. The payment method is not available for + admin order creation (_canUseInternal = false), so the plugin has no impact + on the admin Create Order page or related MFTF tests. +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> +</config> diff --git a/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js b/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js index c02ac67..5722c76 100644 --- a/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js +++ b/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js @@ -45,123 +45,166 @@ define( */ afterPlaceOrder: function () { var checkoutConfig = window.checkoutConfig; - var paymentData = quote.billingAddress(); - var paystackConfiguration = checkoutConfig.payment.pstk_paystack; + var paystackConfiguration = checkoutConfig.payment && checkoutConfig.payment.pstk_paystack; + + if (!paystackConfiguration) { + this.messageContainer.addErrorMessage({ + message: "Paystack configuration is missing. Please contact support." + }); + this.isPlaceOrderActionAllowed(true); + return; + } if (paystackConfiguration.integration_type == 'standard') { this.redirectToCustomAction(paystackConfiguration.integration_type_standard_url); + return; + } + + var paymentData = quote.billingAddress(); + + if (!paymentData) { + this.messageContainer.addErrorMessage({ + message: "Billing address is required to process payment." + }); + this.isPlaceOrderActionAllowed(true); + return; + } + + if (checkoutConfig.isCustomerLoggedIn) { + var customerData = checkoutConfig.customerData; + paymentData.email = customerData.email; } else { - if (checkoutConfig.isCustomerLoggedIn) { - var customerData = checkoutConfig.customerData; - paymentData.email = customerData.email; - } else { - paymentData.email = quote.guestEmail; - } + paymentData.email = quote.guestEmail; + } - var quoteId = checkoutConfig.quoteItemData[0].quote_id; - - var _this = this; - _this.isPlaceOrderActionAllowed(false); - - require(['paystack'], function (PaystackPop) { - var popup = new PaystackPop(); - popup.newTransaction({ - key: paystackConfiguration.public_key, - email: paymentData.email, - amount: Math.ceil(quote.totals().grand_total * 100), - phone: paymentData.telephone, - currency: checkoutConfig.totalsData.quote_currency_code, - metadata: { - quoteId: quoteId, - custom_fields: [ - { - display_name: "QuoteId", - variable_name: "quote id", - value: quoteId - }, - { - display_name: "Address", - variable_name: "address", - value: paymentData.street[0] + ", " + paymentData.street[1] - }, - { - display_name: "Postal Code", - variable_name: "postal_code", - value: paymentData.postcode - }, - { - display_name: "City", - variable_name: "city", - value: paymentData.city + ", " + paymentData.countryId - }, - { - display_name: "Plugin", - variable_name: "plugin", - value: "magento-2" - } - ] - }, - onSuccess: function (response) { - fullScreenLoader.startLoader(); - $.ajax({ - method: "GET", - url: paystackConfiguration.api_url + "V1/paystack/verify/" + response.reference + "_-~-_" + quoteId, - dataType: 'text' - }).done(function (data) { - try { - data = JSON.parse(data); - if (typeof data === 'string') { - data = JSON.parse(data); - } - } catch (e) { - console.error('Payment verification JSON parse error:', e); - fullScreenLoader.stopLoader(); - _this.isPlaceOrderActionAllowed(true); - _this.messageContainer.addErrorMessage({ - message: "Payment verification error." - }); - return; - } + var quoteItems = checkoutConfig.quoteItemData; + var quoteId = quoteItems && quoteItems.length > 0 ? quoteItems[0].quote_id : null; - $.ajax({ - method: 'POST', - url: "https://plugin-tracker.paystackintegrations.com/log/charge_success", - data: { - plugin_name: 'magento-2', - transaction_reference: response.reference, - public_key: paystackConfiguration.public_key - } - }); + if (!quoteId) { + this.messageContainer.addErrorMessage({ + message: "Unable to identify your cart. Please refresh and try again." + }); + this.isPlaceOrderActionAllowed(true); + return; + } + + var streetLines = paymentData.street || []; + var streetAddress = [streetLines[0] || '', streetLines[1] || ''] + .filter(Boolean).join(', '); - if (data.status && data.data && data.data.status === "success") { - redirectOnSuccessAction.execute(); - return; + var _this = this; + _this.isPlaceOrderActionAllowed(false); + + // Stop any loader left active by placeOrderAction before opening the popup. + fullScreenLoader.stopLoader(); + + require(['paystack'], function () { + var PaystackPop = window.PaystackPop; + if (typeof PaystackPop !== 'function') { + fullScreenLoader.stopLoader(); + _this.isPlaceOrderActionAllowed(true); + _this.messageContainer.addErrorMessage({ + message: "Unable to load Paystack. Please check your internet connection and try again." + }); + return; + } + var popup = new PaystackPop(); + popup.newTransaction({ + key: paystackConfiguration.public_key, + email: paymentData.email, + amount: Math.ceil(quote.totals().grand_total * 100), + phone: paymentData.telephone, + currency: checkoutConfig.totalsData.quote_currency_code, + metadata: { + quoteId: quoteId, + custom_fields: [ + { + display_name: "QuoteId", + variable_name: "quote id", + value: quoteId + }, + { + display_name: "Address", + variable_name: "address", + value: streetAddress + }, + { + display_name: "Postal Code", + variable_name: "postal_code", + value: paymentData.postcode + }, + { + display_name: "City", + variable_name: "city", + value: paymentData.city + ", " + paymentData.countryId + }, + { + display_name: "Plugin", + variable_name: "plugin", + value: "magento-2" + } + ] + }, + onSuccess: function (response) { + fullScreenLoader.startLoader(); + $.ajax({ + method: "GET", + url: paystackConfiguration.api_url + "V1/paystack/verify/" + response.reference + "_-~-_" + quoteId, + dataType: 'text' + }).done(function (data) { + try { + data = JSON.parse(data); + if (typeof data === 'string') { + data = JSON.parse(data); } + } catch (e) { + console.error('Payment verification JSON parse error:', e); fullScreenLoader.stopLoader(); _this.isPlaceOrderActionAllowed(true); _this.messageContainer.addErrorMessage({ - message: "Payment verification failed. Status: " + (data.data ? data.data.status : 'unknown') - }); - }).fail(function () { - fullScreenLoader.stopLoader(); - _this.isPlaceOrderActionAllowed(true); - _this.messageContainer.addErrorMessage({ - message: "Payment verification failed." + message: "Payment verification error." }); + return; + } + + $.ajax({ + method: 'POST', + url: "https://plugin-tracker.paystackintegrations.com/log/charge_success", + data: { + plugin_name: 'magento-2', + transaction_reference: response.reference, + public_key: paystackConfiguration.public_key + } }); - }, - onCancel: function () { - _this.redirectToCustomAction(paystackConfiguration.recreate_quote_url); - } - }); - }, function () { - fullScreenLoader.stopLoader(); - _this.isPlaceOrderActionAllowed(true); - _this.messageContainer.addErrorMessage({ - message: "Unable to load Paystack. Please check your internet connection and try again." - }); + + if (data.status && data.data && data.data.status === "success") { + redirectOnSuccessAction.execute(); + return; + } + fullScreenLoader.stopLoader(); + _this.isPlaceOrderActionAllowed(true); + _this.messageContainer.addErrorMessage({ + message: "Payment verification failed. Status: " + (data.data ? data.data.status : 'unknown') + }); + }).fail(function () { + fullScreenLoader.stopLoader(); + _this.isPlaceOrderActionAllowed(true); + _this.messageContainer.addErrorMessage({ + message: "Payment verification failed." + }); + }); + }, + onCancel: function () { + _this.redirectToCustomAction(paystackConfiguration.recreate_quote_url); + } }); - } + }, function () { + fullScreenLoader.stopLoader(); + _this.isPlaceOrderActionAllowed(true); + _this.messageContainer.addErrorMessage({ + message: "Unable to load Paystack. Please check your internet connection and try again." + }); + }); }, }); From 3b940c4be0d22a227a5a23d8424a25c1c2f9de52 Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Tue, 7 Apr 2026 16:06:17 +0200 Subject: [PATCH 07/14] Version bump --- .DS_Store | Bin 8196 -> 8196 bytes README.md | 2 +- composer.json | 2 +- etc/module.xml | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.DS_Store b/.DS_Store index 1dbc8367ec934d4f96039f72e8b097258d93834c..6d0cb4c604fc2657121068dad82f112e32b574cb 100644 GIT binary patch delta 40 wcmZp1XmOa}&nUeyU^hRb^kyD`G?vW|MAVrlHsoz)m-x=IIY)Fh)5L~s02O-<4gdfE delta 134 zcmZp1XmOa}&nUAoU^hRb%w`^eG?rv_h609Sh7yKs23;U4k)aaIOJqm}i{t`D(iu`2 z@_;Jx8H|8xa)ERTLn%WJP*j(}m_d)h0Eo?ixQZc@p};dIKRGEUKZ${XL4bjQQ5J~n UHm8diF>hv<_|CF<f(RQk0Lhgcf&c&j diff --git a/README.md b/README.md index 0847085..b183216 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Paystack payment gateway Magento2 extension -**Version:** 3.0.6 (Paystack v2 Inline.js API) +**Version:** 3.0.7 (Paystack v2 Inline.js API) ## Requirements diff --git a/composer.json b/composer.json index 65cdd0a..f8bba45 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "pstk/paystack-magento2-module", "description": "Paystack Magento2 Module using \\Magento\\Payment\\Model\\Method\\AbstractMethod", - "version": "3.0.6", + "version": "3.0.7", "require": {}, "type": "magento2-module", "license": [ diff --git a/etc/module.xml b/etc/module.xml index f492e80..ece8d41 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> - <module name="Pstk_Paystack" setup_version="3.0.6"> + <module name="Pstk_Paystack" setup_version="3.0.7"> <sequence> <module name="Magento_Sales"/> <module name="Magento_Payment"/> From 3587cd65e6145c0c35a8ab00c4901d416a276caa Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Mon, 22 Jun 2026 12:28:43 +0100 Subject: [PATCH 08/14] Release 3.0.8: add storefront checkout MFTF coverage; harden marketplace build - Add StorefrontPaystackCheckoutRendersTest: drives a guest through cart -> checkout -> shipping -> payment and asserts the checkout renders and the Paystack method appears. Guards against the checkout "loading" hang reported in EQP manual QA; the prior suite only exercised the admin config screen. - build-adobe-zip.sh: exclude docs/ (internal QA artifacts/logs/ recordings), CLAUDE.md, and prior built zips; delete the target before zipping so excluded files don't linger from earlier builds. - Bump version to 3.0.8 (composer.json, etc/module.xml, README.md). --- .DS_Store | Bin 8196 -> 10244 bytes .gitignore | 1 + README.md | 2 +- Test/Mftf/README.md | 7 ++ .../StorefrontPaystackCheckoutRendersTest.xml | 78 ++++++++++++++++++ composer.json | 2 +- etc/module.xml | 2 +- 7 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml diff --git a/.DS_Store b/.DS_Store index 6d0cb4c604fc2657121068dad82f112e32b574cb..1b78f8cfb4ab43ab7c208bd6e788c7ef620beed4 100644 GIT binary patch literal 10244 zcmeHM&u<$=6n<kT&ALe%Crt_!AX)JNq^2Q1R3gLy$8l6yKoiAjNt6_KZEu`y);rtX zwL=;O`3@&8+_>-uRN~eXaN-0<u1N3?aOt^xGrM+n9lPoYp=c)BeKT*~o0<2G-<vlZ zB4YVwZILKLL>eAJ(_?t`DWYFI7fK@J%z`rL6R%b*UO_8;K~^yo0tx|zfI>hapb+?f zAb>Ml6m2x1>a7q^2q*-)2#ENQ!6Rrcr0syp(1Dk{0)P&IEeq;0`3KDi252s%?SO)V z!U9#$K$V483>MxUSfGynTqxfTXrN9^&Wv%wnOS&4Vd2$-Wu!Q<TtL-ZA)pYrh`=mZ zNm?a`%%T;$zptWa{-rEZEk!yt$)`h_r^n<GC%Z@E#~zJg%991TDjmfonV2;?_7qhx zwh8GP-NrmMXjRBS{VBK(X7uPVJ_cbn_El_sYxF5BxlaXJhem^HagD@ZJ+qEuWgK!2 z-X3Oi2U*6xeGSdNmL^1)ids5BYqUZ4Xq&dE2z>6NRz&-5-xa$(+E<|s+_4KTsD(eQ zI&LKpAo@OnOA=@r)asEEdli)$l9*S?i_0ajf*8tOxyrHUgkO7}R~*aq{Dp--SRy$z zd}Tx%(MGk0r9*yP@^!!I<#l&Yw6;07DxJDsd0-mH<@EF#w|(8T4Oc3dH3K61pPP1# zALn_~u6a_o6m)1QEmcn6JUw0d@PoO`^3w9zT;_CfadBlXb7yJc>@20tWZ(a2t9)cN zZ2mQjL}B5XLE}NY`~C*Cu0dwC6&l2T>lpNBI^8iTb#-)X{Mtl%a_aiz^yJLU%#AnR zygBvO+vW82ie5dcyJD$p+~RKDY8vJAga?C*Rc>#(Mhk}S_{Nuh$$W~gsUC$bqxQu) zYx~53%=9@!4tm!$412HcnzfCC1JCexBK2J{Th-;3waGoxH@UsLFK73SM$tatm{F`? z!x3HH1H<(|FX-&->aMHXbt8ZB(9$c0wPEME@AJlv2`j|P<&-5yubOr}`|&>kjHzty zy`Yw+YzJ9wx>E}&%l`a0sBYWbHlVZ_G_$!+LJ^H_;kR3&eR@J|`i_2}XY>pGPJh!2 zddbGw4K~Yeu{m~!t*{(hWm{~IJz_cw$4zuL`JHgQkT4IG?j~FH#Zr|woQO=fufYq2 z@E5H;zjyZ2lV`sQUyAhCUfJ&)4j#Kza&3#-b&Mz0nS@9BRE_(ZjEYg896?mjA!pDr z=t+;Xh)GfDF{&df=!j;Ah?xg;g6PRb#E$DFx1bk|NCWjQi-!+1P<j02_4t9C-s6cQ z$Qg<_Ro8I_muNliEAsS?`q(gi2A>k=snsKib28?Q8cz92m{qhO-{+KO*CMC%723pU z-h>aE{qm}q?7Q%B4{_lL9`1<9AX~b~v%qP9Q^$-VOW?F5IbzLCnJwI=Wn>x)cvkSp z?5f8SktIcKP9alSM`SL*_P%+Qh+DDsr4fe=X^B{+h)F)I5_!wKj80B`Mv+~`^v5M` zK342dIc`r}Q|wPna|qd#A<&4+iJYh>i)mp+h^uiqne9Y(1yHD>5Kssx1TG;0$-t(J z`2N2>`2YWx&~jyxLO>zVL4XYvN`*XHohx*wqeGEqQ3lT=JfaA>c0i>HUP46h<>z=b q#OHYVT>!iV(I*P(xsbL43J%JD{?7n)|3Ck(JMgBY?*Ethi{HP%OvjV} delta 291 zcmZn(XmOBWU|?W$DortDU;r^WfEYvza8E20o2aMAD7`UYH$S8FWF7%;#`TjU1T-0C zCpQT=v#tkftDF2sz?89j@(%&!$)SRxOpHG#=Lp&|rcYia7zt9qFmJMfkjP{iA%50* zK;>aT-V{+o3v)vq1!F_YS{;RIOCtjv1ruYl$-BjjC-Vq%PW~k_7ihr@QC;3{kpBN* zz`!t>Ls9``yq2UU(D)F^Xt4c!la-`&fyx7<?18SBAY}nka!SgdF@3X?^a>_HW}p#3 xAi)hJTtR-?SoocJGQUb72P4F%43pz|rU~YI=Hw?Q<>V&;l?eddi7;a+698g?N$UUr diff --git a/.gitignore b/.gitignore index 0af297a..21c71df 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ dev/.env *.zip build-adobe-zip.log docs +graphify-out \ No newline at end of file diff --git a/README.md b/README.md index b183216..f2cf3bc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Paystack payment gateway Magento2 extension -**Version:** 3.0.7 (Paystack v2 Inline.js API) +**Version:** 3.0.8 (Paystack v2 Inline.js API) ## Requirements diff --git a/Test/Mftf/README.md b/Test/Mftf/README.md index edbdfea..beffa6b 100644 --- a/Test/Mftf/README.md +++ b/Test/Mftf/README.md @@ -17,9 +17,16 @@ Or run by group: vendor/bin/mftf run:group Paystack ``` +Run a single storefront test by name: + +```bash +vendor/bin/mftf run:test StorefrontPaystackCheckoutRendersTest +``` + ## Tests - **PaystackPaymentConfigAvailableTest** – Logs in to admin (using our LoginToAdminActionGroup), opens Stores → Configuration → Sales → Payment Methods, and asserts that “Paystack” is visible. +- **StorefrontPaystackCheckoutRendersTest** – Guest **storefront checkout** coverage. Creates a simple product, enables Paystack (inline), then drives add-to-cart → checkout → shipping → payment and asserts the checkout actually renders (no infinite loading mask) and the Paystack method appears on the payment step. This is the regression guard for the “checkout does not load” class of failure; the prior suite only covered the admin config screen. ## Action groups diff --git a/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml b/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml new file mode 100644 index 0000000..c6dffad --- /dev/null +++ b/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Storefront checkout coverage for the Paystack method. + + Why this test exists: + The only prior automated coverage (PaystackPaymentConfigAvailableTest) checks the + ADMIN config screen. Nothing exercised the storefront checkout, so a "checkout does + not load" class of failure could ship green. This test drives a guest through + cart -> checkout -> shipping -> payment and asserts the checkout actually renders + and the Paystack method appears. If the checkout hangs on the loading mask (the + symptom reported in manual QA), the waitForElement steps time out and this fails. + + Kept self-contained on purpose: raw actions + core createData only, no dependency on + Magento_Checkout/Magento_Catalog ACTION GROUPS, which have failed to generate in the + Adobe pipeline (see LoginToAdminActionGroup.xml note). Selectors are the stock Luma + checkout selectors. +--> +<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> + <test name="StorefrontPaystackCheckoutRendersTest"> + <annotations> + <title value="Storefront checkout loads and offers the Paystack payment method"/> + <description value="Guest adds a product to the cart, proceeds through checkout shipping to the payment step, and the Paystack method renders. Guards against the checkout-page loading hang reported in manual QA."/> + <group value="Paystack"/> + </annotations> + <before> + <!-- A simple product to buy. Price kept inside Paystack's min/max order total. --> + <createData entity="SimpleProduct2" stepKey="createProduct"> + <field key="price">100.00</field> + </createData> + + <!-- Enable Paystack (inline) so it is available at checkout. --> + <magentoCLI command="config:set payment/pstk_paystack/active 1" stepKey="enablePaystack"/> + <magentoCLI command="config:set payment/pstk_paystack/integration_type inline" stepKey="setInline"/> + <magentoCLI command="config:set payment/pstk_paystack/test_mode 1" stepKey="setTestMode"/> + <magentoCLI command="cache:flush" stepKey="flushCache"/> + </before> + + <!-- 1. Add the product to the cart from its storefront page. --> + <amOnPage url="$$createProduct.custom_attributes[url_key]$$.html" stepKey="goToProduct"/> + <waitForPageLoad time="60" stepKey="waitForProductPage"/> + <click selector="#product-addtocart-button" stepKey="addToCart"/> + <waitForElementVisible selector=".message-success" time="60" stepKey="waitForAddedToCart"/> + + <!-- 2. Proceed to checkout. The shipping form must render (this is where the + reported hang occurs: a never-resolving loading mask). --> + <amOnPage url="checkout/" stepKey="goToCheckout"/> + <waitForPageLoad time="60" stepKey="waitForCheckoutPage"/> + <waitForElementVisible selector="#customer-email" time="60" stepKey="assertCheckoutRendered"/> + + <!-- 3. Fill guest shipping details. --> + <fillField selector="#customer-email" userInput="guest@example.com" stepKey="fillEmail"/> + <fillField selector="input[name='firstname']" userInput="Test" stepKey="fillFirstname"/> + <fillField selector="input[name='lastname']" userInput="Buyer" stepKey="fillLastname"/> + <fillField selector="input[name='street[0]']" userInput="1 Test Street" stepKey="fillStreet"/> + <fillField selector="input[name='city']" userInput="Lagos" stepKey="fillCity"/> + <selectOption selector="select[name='region_id']" userInput="California" stepKey="selectRegion"/> + <fillField selector="input[name='postcode']" userInput="90001" stepKey="fillPostcode"/> + <fillField selector="input[name='telephone']" userInput="08000000000" stepKey="fillPhone"/> + + <!-- 4. Select the first shipping method and continue to the payment step. --> + <waitForElementVisible selector=".table-checkout-shipping-method input[type='radio']" time="60" stepKey="waitForShippingMethod"/> + <click selector=".table-checkout-shipping-method input[type='radio']" stepKey="selectShippingMethod"/> + <waitForElementVisible selector="button[data-role='opc-continue']" time="30" stepKey="waitForNext"/> + <click selector="button[data-role='opc-continue']" stepKey="goToPayment"/> + + <!-- 5. Payment step must render and offer the Paystack method (no infinite loader). --> + <waitForElementVisible selector=".checkout-payment-method" time="60" stepKey="assertPaymentStepRendered"/> + <waitForElementVisible selector="#pstk_paystack" time="60" stepKey="assertPaystackRadioPresent"/> + <see userInput="Paystack" stepKey="assertPaystackLabel"/> + + <after> + <deleteData createDataKey="createProduct" stepKey="deleteProduct"/> + <magentoCLI command="config:set payment/pstk_paystack/active 0" stepKey="disablePaystack"/> + <magentoCLI command="cache:flush" stepKey="flushCacheAfter"/> + </after> + </test> +</tests> diff --git a/composer.json b/composer.json index f8bba45..2beaa1f 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "pstk/paystack-magento2-module", "description": "Paystack Magento2 Module using \\Magento\\Payment\\Model\\Method\\AbstractMethod", - "version": "3.0.7", + "version": "3.0.8", "require": {}, "type": "magento2-module", "license": [ diff --git a/etc/module.xml b/etc/module.xml index ece8d41..cdb23d3 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> - <module name="Pstk_Paystack" setup_version="3.0.7"> + <module name="Pstk_Paystack" setup_version="3.0.8"> <sequence> <module name="Magento_Sales"/> <module name="Magento_Payment"/> From 10c4329c1ad3bcc818284bfad53ee9e6858a9a4c Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Fri, 17 Jul 2026 16:55:04 +0200 Subject: [PATCH 09/14] fix: whitelist Paystack CSP domains via csp_whitelist.xml (fixes checkout hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSP PolicyCollector (Model/CspPolicyCollector.php), wired into Magento\Csp\Model\CompositePolicyCollector via etc/frontend/di.xml, replaced Magento's entire Content-Security-Policy with only Paystack's three directives. On Magento 2.4.9 with CSP enforced (the default on checkout/payment pages since 2.4.7), the emitted script-src dropped 'self', so Magento's own require.js was blocked and the Knockout checkout hung on the loading spinner forever — the Adobe Marketplace v3.0.8 rejection ("Loading icon after Proceed to Checkout", reproduced on both CE and EE, PHP 8.5.5). It was never seen locally because dev/ disables CSP and only tests 2.4.8/PHP8.4. Replace it with etc/csp_whitelist.xml, the Magento-standard additive mechanism (as used by core PayPal/Braintree): it appends Paystack hosts to the existing directives without touching Magento's defaults, so 'self' and the inline-script hashes are preserved. Least-privilege host mapping: js.paystack.co in script-src; api.paystack.co/js.paystack.co/plugin-tracker in connect-src; checkout.paystack.com/standard.paystack.co in frame-src. Verified on Magento 2.4.9 CE / PHP 8.5 with CSP enforced: checkout renders, Paystack method appears, Place Order opens the popup iframe, 0 CSP violations, 0 JS errors. - Delete Model/CspPolicyCollector.php and its di.xml registration - Add etc/csp_whitelist.xml - Bump version to 3.0.9 (composer.json, etc/module.xml, README.md) - build-adobe-zip.sh: exclude dev-ee/ and dev-repro/ harnesses from the zip Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .gitignore | 7 +++++- Model/CspPolicyCollector.php | 46 ------------------------------------ README.md | 2 +- build-adobe-zip.sh | 39 ++++++++++++++++++++++++++++++ composer.json | 2 +- etc/csp_whitelist.xml | 46 ++++++++++++++++++++++++++++++++++++ etc/frontend/di.xml | 10 ++------ etc/module.xml | 2 +- 8 files changed, 96 insertions(+), 58 deletions(-) delete mode 100644 Model/CspPolicyCollector.php create mode 100755 build-adobe-zip.sh create mode 100644 etc/csp_whitelist.xml diff --git a/.gitignore b/.gitignore index 21c71df..2bd8dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ dev/.env *.zip build-adobe-zip.log docs -graphify-out \ No newline at end of file +graphify-out +# Local dev harnesses (not committed; also excluded from the Marketplace zip by build-adobe-zip.sh) +dev-ee +dev-ee/* +dev-repro +dev-repro/* diff --git a/Model/CspPolicyCollector.php b/Model/CspPolicyCollector.php deleted file mode 100644 index 8a9c271..0000000 --- a/Model/CspPolicyCollector.php +++ /dev/null @@ -1,46 +0,0 @@ -<?php - -namespace Pstk\Paystack\Model; - -use Magento\Csp\Api\PolicyCollectorInterface; -use Magento\Csp\Model\Policy\FetchPolicy; - -/** - * Registers CSP whitelist entries for Paystack domains. - * - * Uses the PHP collector API instead of csp_whitelist.xml to stay - * backward-compatible across Magento versions (the XML schema changed in 2.4.8). - */ -class CspPolicyCollector implements PolicyCollectorInterface -{ - /** - * @inheritDoc - */ - public function collect(array $defaultPolicies = []): array - { - $policies = $defaultPolicies; - - // script-src: allow loading Paystack Inline JS SDK - $policies[] = new FetchPolicy( - 'script-src', - false, - ['js.paystack.co', 'api.paystack.co'] - ); - - // connect-src: allow XHR/fetch to Paystack APIs - $policies[] = new FetchPolicy( - 'connect-src', - false, - ['api.paystack.co', 'js.paystack.co', 'plugin-tracker.paystackintegrations.com'] - ); - - // frame-src: allow Paystack Standard (redirect) iframe - $policies[] = new FetchPolicy( - 'frame-src', - false, - ['standard.paystack.co'] - ); - - return $policies; - } -} diff --git a/README.md b/README.md index f2cf3bc..8313722 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Paystack payment gateway Magento2 extension -**Version:** 3.0.8 (Paystack v2 Inline.js API) +**Version:** 3.0.9 (Paystack v2 Inline.js API) ## Requirements diff --git a/build-adobe-zip.sh b/build-adobe-zip.sh new file mode 100755 index 0000000..2515a23 --- /dev/null +++ b/build-adobe-zip.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Build a zip of the plugin for Adobe Commerce Marketplace upload. +# Run from the repository root: ./build-adobe-zip.sh +# +set -e + +NAME="pstk-paystack-magento2-module" +VERSION=$(grep -E '"version"' composer.json | sed 's/.*: *"\(.*\)".*/\1/') +ZIP="${NAME}-${VERSION}.zip" + +# Always build fresh — `zip -r` updates in place and would keep stale entries +# (e.g. files that are now excluded) from a previous build. +rm -f "$ZIP" + +# Exclude dev assets, internal docs, archives, and any built zips. +# docs/ holds internal QA artifacts (review logs, recordings) and must never ship. +# graphify-out/ is gitignored internal knowledge-graph tooling cache — must never ship. +zip -r "$ZIP" . \ + -x "*.git*" \ + -x "*.DS_Store" \ + -x "dev/*" \ + -x "dev-ee/*" \ + -x "dev-repro/*" \ + -x "vendor/*" \ + -x ".env" \ + -x "auth.json" \ + -x "CLAUDE.md" \ + -x "docs/*" \ + -x "graphify-out/*" \ + -x "node_modules/*" \ + -x "build-adobe-zip.sh" \ + -x "${NAME}-*.zip" + +echo "Created: $ZIP" +echo "Size: $(du -h "$ZIP" | cut -f1)" +echo "" +echo "Upload $ZIP to Adobe Commerce Marketplace." +unzip -l "$ZIP" | head -30 diff --git a/composer.json b/composer.json index 2beaa1f..01e7cbf 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "pstk/paystack-magento2-module", "description": "Paystack Magento2 Module using \\Magento\\Payment\\Model\\Method\\AbstractMethod", - "version": "3.0.8", + "version": "3.0.9", "require": {}, "type": "magento2-module", "license": [ diff --git a/etc/csp_whitelist.xml b/etc/csp_whitelist.xml new file mode 100644 index 0000000..030c09b --- /dev/null +++ b/etc/csp_whitelist.xml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/** + * Whitelists Paystack domains for Content-Security-Policy (restrict mode). + * + * This is the Magento-standard mechanism (read by the core CspWhitelistXmlCollector). + * It ADDS host sources to the existing directives, so Magento's own defaults + * ('self', inline hashes, etc.) are preserved. The previous PHP PolicyCollector + * injected into CompositePolicyCollector replaced the whole policy set, dropping + * 'self' from script-src and stalling the Knockout checkout on the loader. + * + * Scope note: csp_whitelist.xml is global (there is no area-specific variant, same as + * core Magento_Paypal/Magento_Braintree). The entries are additive and inert in admin + * (admin never loads these hosts), so this does not affect the admin order-create flow. + * + * Hosts (each mapped to only the directive it actually needs — least privilege): + * js.paystack.co script-src (Inline SDK), connect-src (SDK XHR) + * api.paystack.co connect-src (SDK calls /checkout/request_inline etc.) + * plugin-tracker.paystackintegrations.com connect-src (install/charge tracking POST) + * checkout.paystack.com frame-src (v2 inline popup iframe — verified at runtime) + * standard.paystack.co frame-src (Standard/redirect flow) + */ +--> +<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd"> + <policies> + <policy id="script-src"> + <values> + <value id="paystack_js" type="host">js.paystack.co</value> + </values> + </policy> + <policy id="connect-src"> + <values> + <value id="paystack_api" type="host">api.paystack.co</value> + <value id="paystack_js" type="host">js.paystack.co</value> + <value id="paystack_tracker" type="host">plugin-tracker.paystackintegrations.com</value> + </values> + </policy> + <policy id="frame-src"> + <values> + <value id="paystack_checkout" type="host">checkout.paystack.com</value> + <value id="paystack_standard" type="host">standard.paystack.co</value> + </values> + </policy> + </policies> +</csp_whitelist> diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml index 31eed27..c9737a9 100644 --- a/etc/frontend/di.xml +++ b/etc/frontend/di.xml @@ -19,12 +19,6 @@ <plugin name="csrf_validator_skip" type="Pstk\Paystack\Plugin\CsrfValidatorSkip" /> </type> - <!-- CSP: register Paystack domains via PHP collector (frontend only — Paystack is not used in admin) --> - <type name="Magento\Csp\Model\CompositePolicyCollector"> - <arguments> - <argument name="collectors" xsi:type="array"> - <item name="pstk-paystack-csp" xsi:type="object">Pstk\Paystack\Model\CspPolicyCollector</item> - </argument> - </arguments> - </type> + <!-- CSP: Paystack domains are whitelisted via etc/csp_whitelist.xml (the Magento-standard + mechanism). The former PHP PolicyCollector replaced Magento's entire CSP and stalled checkout. --> </config> diff --git a/etc/module.xml b/etc/module.xml index cdb23d3..761c927 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> - <module name="Pstk_Paystack" setup_version="3.0.8"> + <module name="Pstk_Paystack" setup_version="3.0.9"> <sequence> <module name="Magento_Sales"/> <module name="Magento_Payment"/> From 89d041a7a64906644eaf5276b3ae80bc78c43b3f Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Fri, 17 Jul 2026 17:01:18 +0200 Subject: [PATCH 10/14] test(mftf): fix admin config test 404 via page object + core action groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaystackPaymentConfigAvailableTest navigated with a raw `<amOnPage url="admin/system_config/edit/section/payment"/>` — a string MFTF does not normalize, so it resolved relative to the post-login dashboard as `/admin/admin/system_config/...` -> noRoute 404, which failed the test in the Adobe Marketplace pipeline. - Add Test/Mftf/Page/AdminPaymentConfigPage.xml (area="admin" page object; MFTF emits a leading-slash, base-relative URL) - Navigate via {{AdminPaymentConfigPage.url}} and use core AdminLoginActionGroup / AdminLogoutActionGroup instead of the custom LoginToAdminActionGroup (deleted) - StorefrontPaystackCheckoutRendersTest: leading-slash amOnPage URLs (same relative-URL bug class) - Untrack .DS_Store and ignore it going forward Test-only; no production code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .DS_Store | Bin 10244 -> 0 bytes .gitignore | 4 ++++ .../ActionGroup/LoginToAdminActionGroup.xml | 20 ------------------ Test/Mftf/Page/AdminPaymentConfigPage.xml | 20 ++++++++++++++++++ Test/Mftf/README.md | 8 +++---- .../PaystackPaymentConfigAvailableTest.xml | 19 +++++++++++------ .../StorefrontPaystackCheckoutRendersTest.xml | 11 +++++----- 7 files changed, 46 insertions(+), 36 deletions(-) delete mode 100644 .DS_Store delete mode 100644 Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml create mode 100644 Test/Mftf/Page/AdminPaymentConfigPage.xml diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 1b78f8cfb4ab43ab7c208bd6e788c7ef620beed4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHM&u<$=6n<kT&ALe%Crt_!AX)JNq^2Q1R3gLy$8l6yKoiAjNt6_KZEu`y);rtX zwL=;O`3@&8+_>-uRN~eXaN-0<u1N3?aOt^xGrM+n9lPoYp=c)BeKT*~o0<2G-<vlZ zB4YVwZILKLL>eAJ(_?t`DWYFI7fK@J%z`rL6R%b*UO_8;K~^yo0tx|zfI>hapb+?f zAb>Ml6m2x1>a7q^2q*-)2#ENQ!6Rrcr0syp(1Dk{0)P&IEeq;0`3KDi252s%?SO)V z!U9#$K$V483>MxUSfGynTqxfTXrN9^&Wv%wnOS&4Vd2$-Wu!Q<TtL-ZA)pYrh`=mZ zNm?a`%%T;$zptWa{-rEZEk!yt$)`h_r^n<GC%Z@E#~zJg%991TDjmfonV2;?_7qhx zwh8GP-NrmMXjRBS{VBK(X7uPVJ_cbn_El_sYxF5BxlaXJhem^HagD@ZJ+qEuWgK!2 z-X3Oi2U*6xeGSdNmL^1)ids5BYqUZ4Xq&dE2z>6NRz&-5-xa$(+E<|s+_4KTsD(eQ zI&LKpAo@OnOA=@r)asEEdli)$l9*S?i_0ajf*8tOxyrHUgkO7}R~*aq{Dp--SRy$z zd}Tx%(MGk0r9*yP@^!!I<#l&Yw6;07DxJDsd0-mH<@EF#w|(8T4Oc3dH3K61pPP1# zALn_~u6a_o6m)1QEmcn6JUw0d@PoO`^3w9zT;_CfadBlXb7yJc>@20tWZ(a2t9)cN zZ2mQjL}B5XLE}NY`~C*Cu0dwC6&l2T>lpNBI^8iTb#-)X{Mtl%a_aiz^yJLU%#AnR zygBvO+vW82ie5dcyJD$p+~RKDY8vJAga?C*Rc>#(Mhk}S_{Nuh$$W~gsUC$bqxQu) zYx~53%=9@!4tm!$412HcnzfCC1JCexBK2J{Th-;3waGoxH@UsLFK73SM$tatm{F`? z!x3HH1H<(|FX-&->aMHXbt8ZB(9$c0wPEME@AJlv2`j|P<&-5yubOr}`|&>kjHzty zy`Yw+YzJ9wx>E}&%l`a0sBYWbHlVZ_G_$!+LJ^H_;kR3&eR@J|`i_2}XY>pGPJh!2 zddbGw4K~Yeu{m~!t*{(hWm{~IJz_cw$4zuL`JHgQkT4IG?j~FH#Zr|woQO=fufYq2 z@E5H;zjyZ2lV`sQUyAhCUfJ&)4j#Kza&3#-b&Mz0nS@9BRE_(ZjEYg896?mjA!pDr z=t+;Xh)GfDF{&df=!j;Ah?xg;g6PRb#E$DFx1bk|NCWjQi-!+1P<j02_4t9C-s6cQ z$Qg<_Ro8I_muNliEAsS?`q(gi2A>k=snsKib28?Q8cz92m{qhO-{+KO*CMC%723pU z-h>aE{qm}q?7Q%B4{_lL9`1<9AX~b~v%qP9Q^$-VOW?F5IbzLCnJwI=Wn>x)cvkSp z?5f8SktIcKP9alSM`SL*_P%+Qh+DDsr4fe=X^B{+h)F)I5_!wKj80B`Mv+~`^v5M` zK342dIc`r}Q|wPna|qd#A<&4+iJYh>i)mp+h^uiqne9Y(1yHD>5Kssx1TG;0$-t(J z`2N2>`2YWx&~jyxLO>zVL4XYvN`*XHohx*wqeGEqQ3lT=JfaA>c0i>HUP46h<>z=b q#OHYVT>!iV(I*P(xsbL43J%JD{?7n)|3Ck(JMgBY?*Ethi{HP%OvjV} diff --git a/.gitignore b/.gitignore index 2bd8dc8..5c85352 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ dev-ee dev-ee/* dev-repro dev-repro/* + +# macOS +.DS_Store +**/.DS_Store diff --git a/Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml b/Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml deleted file mode 100644 index 07c2c88..0000000 --- a/Test/Mftf/ActionGroup/LoginToAdminActionGroup.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- - Self-contained admin login so the vendor test does not depend on Magento_Backend - action groups during MFTF generation (avoids "failed to generate" in Adobe pipeline). ---> -<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd"> - <actionGroup name="LoginToAdminActionGroup"> - <arguments> - <argument name="adminUser" type="string" defaultValue="admin"/> - <argument name="adminPassword" type="string" defaultValue=""/> - </arguments> - <amOnPage stepKey="navigateToAdmin" url="admin"/> - <waitForPageLoad stepKey="waitForAdminPage" time="30"/> - <fillField stepKey="fillUsername" selector="#username" userInput="{{adminUser}}"/> - <fillField stepKey="fillPassword" selector="#login" userInput="{{adminPassword}}"/> - <click stepKey="clickLogin" selector=".actions .action-primary"/> - <waitForPageLoad stepKey="waitForDashboard" time="30"/> - </actionGroup> -</actionGroups> diff --git a/Test/Mftf/Page/AdminPaymentConfigPage.xml b/Test/Mftf/Page/AdminPaymentConfigPage.xml new file mode 100644 index 0000000..af3661b --- /dev/null +++ b/Test/Mftf/Page/AdminPaymentConfigPage.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Admin "Stores > Configuration > Sales > Payment Methods" page. + + Declared as a proper area="admin" page object on purpose: MFTF normalizes + admin page-object URLs to a leading-slash, base-relative form at generation + time (e.g. "/admin/system_config/edit/section/payment"). A raw + <amOnPage url="admin/system_config/..."/> string does NOT get that treatment + and resolves relative to the current (post-login dashboard) URL, producing + /admin/admin/system_config/... -> noRoute 404. That 404 is exactly what made + PaystackPaymentConfigAvailableTest fail in the Adobe marketplace pipeline. +--> +<pages xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:mftf:Page/etc/PageObject.xsd"> + <page name="AdminPaymentConfigPage" + url="admin/system_config/edit/section/payment" + area="admin" + module="Pstk_Paystack"> + </page> +</pages> diff --git a/Test/Mftf/README.md b/Test/Mftf/README.md index beffa6b..fcb0122 100644 --- a/Test/Mftf/README.md +++ b/Test/Mftf/README.md @@ -1,6 +1,6 @@ # MFTF tests for Pstk_Paystack -This module provides **vendor-supplied** MFTF tests for the Paystack payment extension. The test and action group are self-contained (no refs to Magento_Backend) so generation succeeds in the Adobe Commerce Marketplace pipeline. +This module provides **vendor-supplied** MFTF tests for the Paystack payment extension. They use Magento's standard action groups and page objects, mirroring how the Adobe Commerce Marketplace pipeline's own baseline suite navigates. ## Running @@ -25,9 +25,9 @@ vendor/bin/mftf run:test StorefrontPaystackCheckoutRendersTest ## Tests -- **PaystackPaymentConfigAvailableTest** – Logs in to admin (using our LoginToAdminActionGroup), opens Stores → Configuration → Sales → Payment Methods, and asserts that “Paystack” is visible. +- **PaystackPaymentConfigAvailableTest** – Logs in to admin (`AdminLoginActionGroup`), opens Stores → Configuration → Sales → Payment Methods via the `AdminPaymentConfigPage` page object, and asserts that “Paystack” is visible. - **StorefrontPaystackCheckoutRendersTest** – Guest **storefront checkout** coverage. Creates a simple product, enables Paystack (inline), then drives add-to-cart → checkout → shipping → payment and asserts the checkout actually renders (no infinite loading mask) and the Paystack method appears on the payment step. This is the regression guard for the “checkout does not load” class of failure; the prior suite only covered the admin config screen. -## Action groups +## Pages -- **LoginToAdminActionGroup** – Self-contained admin login (navigate to admin, fill username/password, click login). Used so the vendor test does not depend on Magento_Backend during MFTF generation. +- **AdminPaymentConfigPage** – `area="admin"` page object for the Payment Methods config section. Using a page object (rather than a raw `admin/...` `amOnPage` string) makes MFTF emit a leading-slash, base-relative URL, avoiding the `/admin/admin/...` noRoute 404 that failed the original submission. diff --git a/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml index 3434eed..dd6091c 100644 --- a/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml +++ b/Test/Mftf/Test/PaystackPaymentConfigAvailableTest.xml @@ -8,16 +8,23 @@ <group value="Paystack"/> </annotations> <before> - <actionGroup stepKey="loginAsAdmin" ref="LoginToAdminActionGroup"> - <argument name="adminUser" value="admin"/> - <argument name="adminPassword" value="{{_ENV.MAGENTO_ADMIN_PASSWORD}}"/> - </actionGroup> + <!-- + Use Magento's standard admin login. The Adobe pipeline's own + mftfmagento suite uses this action group and passes, so the prior + concern about core action groups "failing to generate" does not hold. + --> + <actionGroup stepKey="loginAsAdmin" ref="AdminLoginActionGroup"/> </before> - <amOnPage stepKey="goToPaymentConfig" url="admin/system_config/edit/section/payment"/> + <!-- + Navigate via the area="admin" page object so MFTF emits a leading-slash, + base-relative URL. A raw <amOnPage url="admin/..."/> resolves relative to + the post-login dashboard and 404s (the original submission failure). + --> + <amOnPage stepKey="goToPaymentConfig" url="{{AdminPaymentConfigPage.url}}"/> <waitForPageLoad stepKey="waitForPaymentConfig" time="60"/> <see stepKey="seePaystackSection" userInput="Paystack"/> <after> - <amOnPage stepKey="logout" url="admin/admin/auth/logout/"/> + <actionGroup stepKey="logout" ref="AdminLogoutActionGroup"/> </after> </test> </tests> diff --git a/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml b/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml index c6dffad..7be5d86 100644 --- a/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml +++ b/Test/Mftf/Test/StorefrontPaystackCheckoutRendersTest.xml @@ -10,10 +10,9 @@ and the Paystack method appears. If the checkout hangs on the loading mask (the symptom reported in manual QA), the waitForElement steps time out and this fails. - Kept self-contained on purpose: raw actions + core createData only, no dependency on - Magento_Checkout/Magento_Catalog ACTION GROUPS, which have failed to generate in the - Adobe pipeline (see LoginToAdminActionGroup.xml note). Selectors are the stock Luma - checkout selectors. + Uses raw actions + core createData and the stock Luma checkout selectors. amOnPage + URLs are base-relative (leading slash) so they resolve against the site root rather + than the current page. --> <tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd"> @@ -37,14 +36,14 @@ </before> <!-- 1. Add the product to the cart from its storefront page. --> - <amOnPage url="$$createProduct.custom_attributes[url_key]$$.html" stepKey="goToProduct"/> + <amOnPage url="/$$createProduct.custom_attributes[url_key]$$.html" stepKey="goToProduct"/> <waitForPageLoad time="60" stepKey="waitForProductPage"/> <click selector="#product-addtocart-button" stepKey="addToCart"/> <waitForElementVisible selector=".message-success" time="60" stepKey="waitForAddedToCart"/> <!-- 2. Proceed to checkout. The shipping form must render (this is where the reported hang occurs: a never-resolving loading mask). --> - <amOnPage url="checkout/" stepKey="goToCheckout"/> + <amOnPage url="/checkout/" stepKey="goToCheckout"/> <waitForPageLoad time="60" stepKey="waitForCheckoutPage"/> <waitForElementVisible selector="#customer-email" time="60" stepKey="assertCheckoutRendered"/> From b5c1ca77f12d4618606c4cadc266703a6e9d0db8 Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Fri, 17 Jul 2026 17:05:09 +0200 Subject: [PATCH 11/14] Dev with claude --- CLAUDE.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bf28747 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Module Identity + +- **Module name**: `Pstk_Paystack` +- **PHP namespace**: `Pstk\Paystack` +- **Composer package**: `pstk/paystack-magento2-module` +- **Magento payment method code**: `pstk_paystack` (constant `Pstk\Paystack\Model\Payment\Paystack::CODE`) +- **Requires**: Magento 2.4.x, PHP 8.2+ (this is the supported target, but note `composer.json` has an empty `require: {}` — these constraints are **not** enforced by Composer) + +## Build + +```bash +# Build zip for Adobe Commerce Marketplace +./build-adobe-zip.sh +# Output: pstk-paystack-magento2-module-<version>.zip +``` + +`build-adobe-zip.sh` reads the version from `composer.json` and always rebuilds from scratch (removes any stale zip first). Its exclusion list is `.git*`, `.DS_Store`, `dev/`, `vendor/`, `.env`, `auth.json`, `CLAUDE.md`, `docs/`, `graphify-out/`, `node_modules/`, the build script itself, and prior `*.zip` builds — so `CLAUDE.md`, internal QA artifacts, and tooling caches never ship to Marketplace. + +> ⚠️ **The exclusion list does NOT include `dev-ee/`** (added after the script was written). A fresh build will bundle the entire EE test harness into the Marketplace zip. Add `-x "dev-ee/*"` before building a release, or the harness will ship. (The committed `pstk-paystack-magento2-module-3.0.8.zip` predates `dev-ee/`, so it is clean.) + +### Versioning + +The version string lives in **three** places that must be kept in sync on a version bump: +- `composer.json` (`version`) — source of truth used by the build script +- `etc/module.xml` (`setup_version` on `<module>`) +- `README.md` (the **Version:** line) + +## Development Environment + +```bash +cd dev +cp .env.example .env +docker compose up -d +bash setup.sh +``` + +The `dev/` directory contains a full Docker-based Magento 2 environment (Magento 2.4.8 via the Mage-OS mirror — no Adobe Marketplace auth needed). `docker compose up -d` builds the image and installs Magento on first run (~8 min total); `setup.sh` enables the module, disables 2FA, sets developer mode, and seeds test data (`dev/seed-products.php`). Containers: `paystack-magento`, `paystack-db`, `paystack-search`. + +- Storefront: `http://localhost:8080` · Admin: `http://localhost:8080/admin` (`admin` / `Admin12345!`) +- Paystack test card: `4084 0840 8408 4081`, exp `12/30`, CVV `408`, PIN `0000`, OTP `123456` +- `docker compose down -v` resets all data. + +The local `dev/` env uses the Mage-OS **Community Edition** mirror. It **cannot** reproduce Adobe Commerce (Enterprise) issues — those depend on EE-only layers (Varnish FPC, Content Staging). For EE-specific reproduction there is a separate `dev-ee/` harness (see below). + +### `dev-ee/` — Enterprise baseline harness + +`dev-ee/` is a standalone EE + Varnish + Selenium + MFTF harness (`run-ee-baseline.sh`) built to determine whether two EE-only MFTF failures reported in Adobe's QA (`MC-84` AdminConfigurableProductCreateTest, `MC-26602` AdminCreateGroupedProductTest) are caused by this module or are pre-existing EE core-test flakiness. It runs each test N times in two arms (no-module vs with-module) and compares failure rates. It is the empirical counterpart to `docs/EE-NO-MODULE-BASELINE.md`. + +**Status:** written without valid Adobe Commerce keys and **never executed end-to-end** — the local `auth.json` keys return HTTP 401, and `check-ee-keys.sh` preflight refuses to run until they work. Treat `run-ee-baseline.sh` as a runbook, not a one-shot; fragile spots are marked `SEAM:` with manual fallbacks in its README. + +## Testing + +Tests use the Magento Functional Testing Framework (MFTF), located in `Test/Mftf/`. MFTF tests run against a live Magento instance: + +```bash +# From Magento root (not this repo root) +vendor/bin/mftf run:test PaystackPaymentConfigAvailableTest +vendor/bin/mftf run:test StorefrontPaystackCheckoutRendersTest +``` + +Current tests (`Test/Mftf/Test/`): `PaystackPaymentConfigAvailableTest.xml` and `StorefrontPaystackCheckoutRendersTest.xml`, backed by the page object `Test/Mftf/Page/AdminPaymentConfigPage.xml`. `Test/Mftf/Suite/` exists but is **empty** — there are no suites, so `vendor/bin/mftf run:suite` has nothing to run. + +There are no unit tests — the test suite is entirely MFTF (browser-level functional tests). There is no configured linter or static-analysis tooling (no PHPCS/PHPStan config, no composer `scripts`); match the surrounding code style by hand. The only CI is `.github/workflows/codeql-analysis.yml` (CodeQL security scanning). + +The `docs/` directory (gitignored, never shipped) holds local MFTF Allure report artifacts (`mftfmagento/`, `mftfvendor/`) plus the `EE-NO-MODULE-BASELINE.md` analysis — it is not module code. + +## Architecture + +### Payment Flows + +There are two integration types, selectable in admin config: + +**Inline (default)**: Paystack popup opens in the browser after order is placed. +1. JS calls `afterPlaceOrder()` → Paystack popup opens +2. On success, JS calls `GET /V1/paystack/verify/{reference}_{quoteId}` (REST API, anonymous) +3. `PaymentManagement::verifyPayment()` verifies with Paystack API, dispatches `paystack_payment_verify_after` +4. `ObserverAfterPaymentVerify` sets order to Processing and sends confirmation email + +**Standard (redirect)**: Customer is redirected to Paystack's hosted page. +1. `/paystack/payment/setup` — initializes transaction, redirects to Paystack +2. `/paystack/payment/callback` — Paystack returns here; verifies transaction, dispatches `paystack_payment_verify_after` +3. `/paystack/payment/recreate` — retry path: cancels the failed/abandoned order, restores the quote, and redirects back to the checkout payment step + +**Webhook** (independent, server-to-server): +- `/paystack/payment/webhook` — receives `charge.success` events from Paystack +- Validates HMAC-SHA512 signature, verifies transaction, dispatches `paystack_payment_verify_after` +- CSRF validation skipped via `Plugin/CsrfValidatorSkip.php` + +The custom event `paystack_payment_verify_after` is the single point where order status is updated to Processing and confirmation email is sent (`Observer/ObserverAfterPaymentVerify.php`). Initial order confirmation email is suppressed by `ObserverBeforeSalesOrderPlace` until payment is verified. + +### Key Classes + +| Class | Responsibility | +|---|---| +| `Gateway/PaystackApiClient.php` | All Paystack API calls: initialize transaction, verify, validate webhook signature | +| `Model/PaymentManagement.php` | REST API endpoint for inline payment verification | +| `Model/Ui/ConfigProvider.php` | Injects public key, integration type, and URLs into checkout JS config | +| `Controller/Payment/AbstractPaystackStandard.php` | Base controller with shared utilities (quote loading, message handling) | +| `etc/csp_whitelist.xml` | Whitelists Paystack domains in Magento's Content Security Policy (additive; the Magento-standard mechanism) | + +### DI Configuration Scoping + +This is critical — the payment method is intentionally unavailable in admin order creation: + +- `etc/frontend/di.xml` — registers `ConfigProvider`, `PaymentManagementInterface` preference, CSRF-skip plugin (CSP is handled by `etc/csp_whitelist.xml`, not here) +- `etc/adminhtml/di.xml` — intentionally empty (prevents EE admin crash on order create) +- `etc/webapi_rest/di.xml` — `PaymentManagementInterface` preference for REST API calls +- `etc/di.xml` — root scope (minimal) + +### Configuration Path + +All settings live under `payment/pstk_paystack/` in Magento config. Secret keys use the `Encrypted` backend model. Test mode toggles between test/live key pairs in `PaystackApiClient`. + +### Quote ID as Transaction Anchor + +For inline payments, Paystack generates the transaction reference on the client side. The `quoteId` is passed as metadata in the Paystack transaction so the webhook/verification can locate the correct order when no Magento-generated reference is available. From 374e1f8bc021fe30979e45ebc9060725f719ce0e Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Fri, 17 Jul 2026 17:54:41 +0200 Subject: [PATCH 12/14] fix: remove curl_close() calls that broke payment verification on PHP 8.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curl_close() is deprecated since PHP 8.5 (a no-op since PHP 8.0, where the curl handle became a \CurlHandle object freed automatically). Magento escalates the deprecation notice to a thrown exception, and PaystackApiClient::request() calls curl_close() right after curl_exec() — so verifyPayment() caught the deprecation AFTER the Paystack API had already confirmed the charge. Result: the payment went through but the checkout showed "Payment verification failed". Remove all three curl_close() calls. Verified on Magento 2.4.9 / PHP 8.5: the verify endpoint now runs the curl request to completion (bogus reference returns the real "Transaction reference not found" instead of the curl_close deprecation), and the success path returns {status:true, data:{status:"success"}} unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- Gateway/PaystackApiClient.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Gateway/PaystackApiClient.php b/Gateway/PaystackApiClient.php index 9f3372f..6a43e9f 100644 --- a/Gateway/PaystackApiClient.php +++ b/Gateway/PaystackApiClient.php @@ -100,7 +100,6 @@ public function logTransactionSuccess(string $transactionReference, string $publ CURLOPT_TIMEOUT => 5, ]); curl_exec($ch); - curl_close($ch); } /** @@ -134,12 +133,15 @@ private function request(string $method, string $endpoint, ?array $data = null): if (curl_errno($ch)) { $error = curl_error($ch); - curl_close($ch); throw new ApiException('Paystack API request failed: ' . $error); } $statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + // Note: curl_close() is intentionally omitted. Since PHP 8.0 the curl handle is a + // \CurlHandle object that is freed automatically, and calling curl_close() emits a + // deprecation notice on PHP 8.5 which Magento escalates to an exception — that was + // aborting verifyPayment() after a successful charge ("payment verification failed" + // while the payment went through). $body = json_decode($response); From 3584319101a834b4ba68301930fcbc0b6e4cc075 Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Fri, 17 Jul 2026 17:59:44 +0200 Subject: [PATCH 13/14] chore: bump version to 3.0.10 Release combining the CSP checkout-hang fix (csp_whitelist.xml), the PHP 8.5 curl_close() payment-verification fix, and the merged EE admin-crash fix + unit test suite. Verified end-to-end on Magento 2.4.9 / PHP 8.5 with CSP enforced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- README.md | 2 +- composer.json | 2 +- etc/module.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8313722..bbd5673 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Paystack payment gateway Magento2 extension -**Version:** 3.0.9 (Paystack v2 Inline.js API) +**Version:** 3.0.10 (Paystack v2 Inline.js API) ## Requirements diff --git a/composer.json b/composer.json index 01e7cbf..8b87301 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "pstk/paystack-magento2-module", "description": "Paystack Magento2 Module using \\Magento\\Payment\\Model\\Method\\AbstractMethod", - "version": "3.0.9", + "version": "3.0.10", "require": {}, "type": "magento2-module", "license": [ diff --git a/etc/module.xml b/etc/module.xml index 761c927..167612a 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> - <module name="Pstk_Paystack" setup_version="3.0.9"> + <module name="Pstk_Paystack" setup_version="3.0.10"> <sequence> <module name="Magento_Sales"/> <module name="Magento_Payment"/> From 4a924c3f787e55b166ff4aa8ee6fb7ef92ed2a45 Mon Sep 17 00:00:00 2001 From: Jules Nsenda <jules@paystack.com> Date: Fri, 17 Jul 2026 18:06:46 +0200 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20add=20CHANGELOG=20covering=203.0.?= =?UTF-8?q?5=E2=80=933.0.10=20(since=20tag=20v3.0.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- CHANGELOG.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..844ffec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# Changelog + +All notable changes to the Paystack Magento 2 module are documented here. +This project adheres to [Semantic Versioning](https://semver.org/). + +The entries below cover every release since the last tag, **v3.0.4**. + +## [3.0.10] - 2026-07-17 + +Consolidated release for Magento 2.4.9 / PHP 8.5, verified end-to-end with +Content-Security-Policy enforced. + +### Fixed +- **Payment verification failed on PHP 8.5 even when the payment succeeded.** + `Gateway/PaystackApiClient.php` called `curl_close()`, which is deprecated in + PHP 8.5 (a no-op since PHP 8.0). Magento escalates the deprecation to an + exception, and it fired *after* the charge was confirmed — so customers saw + "Payment verification failed" despite a successful payment. Removed all + `curl_close()` calls (the handle is freed automatically). +- **Admin order creation on Adobe Commerce (EE).** The payment method now + implements `MethodInterface` directly instead of extending `AbstractMethod`, + and `getInfoInstance()` matches the expected contract, preventing EE-only + interceptors from crashing admin pages. Adds a defence-in-depth admin-area guard. + +### Added +- PHPUnit unit-test suite (`Test/Unit/**`, `phpunit.xml`) covering the payment + model, controllers, gateway client, observers, config provider, and plugins. +- End-to-end test coverage and a dedicated admin-config MFTF page object/section. + +## [3.0.9] - 2026-07-17 + +Superseded by 3.0.10 (its fixes are included there). + +### Fixed +- **Checkout page hung on the loading spinner under enforced CSP.** The module's + PHP CSP `PolicyCollector` replaced Magento's entire Content-Security-Policy, + dropping `'self'` from `script-src` and blocking Magento's own JavaScript on the + checkout page (CSP is enforced by default on checkout/payment pages since 2.4.7). + Replaced with the standard, additive `etc/csp_whitelist.xml` mechanism. +- **MFTF `PaystackPaymentConfigAvailableTest` 404'd in the Adobe pipeline.** The + test navigated with a raw `amOnPage url="admin/..."` that resolved to + `/admin/admin/...`. Switched to an `area="admin"` page object (emits a correct + base-relative URL) and core `AdminLoginActionGroup`/`AdminLogoutActionGroup`. + +## [3.0.8] - 2026-06-22 + +### Added +- Storefront guest-checkout MFTF coverage (`StorefrontPaystackCheckoutRendersTest`) + guarding against the "checkout does not load" class of failure. + +### Changed +- Hardened the Adobe Marketplace build script so internal artifacts are excluded + from the published zip. + +## [3.0.7] - 2026-04-07 + +### Changed +- Version bump (no functional changes). + +## [3.0.6] - 2026-04-07 + +### Changed +- Reworked the checkout method-renderer JavaScript to lazy-load the Paystack Inline + SDK, so a slow/blocked SDK no longer stalls checkout rendering. +- Updated `ConfigProvider` and the payment model. + +### Added +- First vendor MFTF test (`PaystackPaymentConfigAvailableTest`) verifying the + payment method appears in admin configuration. +- Empty `etc/adminhtml/di.xml` to keep the module out of the admin DI scope. + +## [3.0.5] - 2026-03-06 + +### Fixed +- **Admin order-create crash on Adobe Commerce (EE).** Scoped the + `PaymentManagementInterface` preference to `frontend`/`webapi_rest` (removed from + the global/admin scope) and lazy-loaded the payment method, so admin order + creation and MFTF tests are no longer affected by frontend-only dependencies. + +--- + +_Note: 3.0.5–3.0.10 were not individually tagged; see the commit history since +[`v3.0.4`](https://github.com/PaystackHQ/plugin-magento-2/releases/tag/v3.0.4) for details._