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/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/Payment/Paystack.php b/Model/Payment/Paystack.php index 0a72f67..5e59139 100644 --- a/Model/Payment/Paystack.php +++ b/Model/Payment/Paystack.php @@ -1,42 +1,378 @@ . */ 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; +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. + * + * 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 \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; + + /** @var State */ + private $appState; + + public function __construct( + ScopeConfigInterface $scopeConfig, + State $appState, + array $data = [] ) { - return parent::isAvailable($quote); + $this->scopeConfig = $scopeConfig; + $this->appState = $appState; + 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) + { + if ($this->isAdminArea()) { + return false; + } + $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() + { + if (!$this->infoInstance instanceof InfoInterface) { + throw new LocalizedException( + __('We cannot retrieve the payment information object instance.') + ); + } + 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.') + ); + } + + // ------------------------------------------------------------------------- + // 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/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/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/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> 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..e6b649f --- /dev/null +++ b/Test/Unit/Model/Payment/PaystackTest.php @@ -0,0 +1,380 @@ +<?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\App\State; +use Magento\Framework\App\Area; +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; + + /** @var MockObject|State */ + private $appState; + + protected function setUp(): void + { + $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $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 ---- + + 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 testIsAvailableReturnsFalseInAdminArea(): void + { + $this->scopeConfig->method('getValue')->willReturn('1'); + + $adminModel = $this->createAdminModel(); + $this->assertFalse($adminModel->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 testGetInfoInstanceThrowsWhenNotSet(): void + { + $this->expectException(LocalizedException::class); + $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/composer.json b/composer.json index c834533..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.4", + "version": "3.0.6", "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 @@ <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> - - <!-- API --> - <preference for="Pstk\Paystack\Api\PaymentManagementInterface" type="Pstk\Paystack\Model\PaymentManagement"/> - </config> 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 @@ <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <!-- Payment verify API (frontend + webapi_rest only — never in admin) --> + <preference for="Pstk\Paystack\Api\PaymentManagementInterface" type="Pstk\Paystack\Model\PaymentManagement"/> + <type name="Pstk\Paystack\Model\PaymentManagement"> + <arguments> + <argument name="checkoutSession" xsi:type="object">Magento\Checkout\Model\Session\Proxy</argument> + </arguments> + </type> + <type name="Magento\Checkout\Model\CompositeConfigProvider"> <arguments> <argument name="configProviders" xsi:type="array"> @@ -11,7 +19,7 @@ <plugin name="csrf_validator_skip" type="Pstk\Paystack\Plugin\CsrfValidatorSkip" /> </type> - <!-- CSP: register Paystack domains via PHP collector (frontend only - Paystack is not used in admin) --> + <!-- CSP: register Paystack domains via PHP collector (frontend only — Paystack is not used in admin) --> <type name="Magento\Csp\Model\CompositePolicyCollector"> <arguments> <argument name="collectors" xsi:type="array"> diff --git a/etc/module.xml b/etc/module.xml index b016bf6..f492e80 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> - <module name="Pstk_Paystack" setup_version="3.0.4"> + <module name="Pstk_Paystack" setup_version="3.0.6"> <sequence> <module name="Magento_Sales"/> <module name="Magento_Payment"/> 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 @@ +<?xml version="1.0"?> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <!-- Payment verify API for REST calls --> + <preference for="Pstk\Paystack\Api\PaymentManagementInterface" type="Pstk\Paystack\Model\PaymentManagement"/> + <type name="Pstk\Paystack\Model\PaymentManagement"> + <arguments> + <argument name="checkoutSession" xsi:type="object">Magento\Checkout\Model\Session\Proxy</argument> + </arguments> + </type> +</config> 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>