From 78f04a72b5d10f4b9dc9f647f8e596676f8c0de1 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Fri, 6 Mar 2026 16:21:55 +0200 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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(); } }