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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
+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 @@
-
-
-
-
diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml
index 888e464..31eed27 100644
--- a/etc/frontend/di.xml
+++ b/etc/frontend/di.xml
@@ -1,5 +1,13 @@
+
+
+
+
+ Magento\Checkout\Model\Session\Proxy
+
+
+
@@ -11,7 +19,7 @@
-
+
diff --git a/etc/module.xml b/etc/module.xml
index b016bf6..f492e80 100644
--- a/etc/module.xml
+++ b/etc/module.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/etc/webapi_rest/di.xml b/etc/webapi_rest/di.xml
new file mode 100644
index 0000000..2766c27
--- /dev/null
+++ b/etc/webapi_rest/di.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Magento\Checkout\Model\Session\Proxy
+
+
+
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..d5e5c35
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ Test/Unit
+
+
+
+
+ .
+
+
+ Test
+ dev
+ docs
+ vendor
+
+
+