| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- <?php
- namespace Webkul\BagistoApi\Tests\Unit\Services;
- use Illuminate\Support\Facades\Event;
- use Mockery;
- use ReflectionMethod;
- use Tests\TestCase;
- use Webkul\BagistoApi\Dto\PaymentCallbackInput;
- use Webkul\BagistoApi\Exception\OperationFailedException;
- use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
- use Webkul\BagistoApi\Services\CartTokenService;
- use Webkul\BagistoApi\Services\PaymentService;
- use Webkul\Checkout\Repositories\CartRepository;
- use Webkul\Paypal\Payment\SmartButton;
- use Webkul\Sales\Models\Order;
- use Webkul\Sales\Repositories\InvoiceRepository;
- use Webkul\Sales\Repositories\OrderRepository;
- use Webkul\Sales\Repositories\OrderTransactionRepository;
- /**
- * Coverage for the P0 payment-success hardening:
- *
- * - callback success is idempotent (no re-capture on an order that
- * already moved past pending)
- * - the PayPal capture response is normalized correctly
- * - the captured amount/currency is verified before the order is paid
- * - capture failures / non-completed captures surface as errors
- */
- class PaymentServiceCaptureTest extends TestCase
- {
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
- private function makeService(?OrderRepository $orderRepository = null): PaymentService
- {
- return new PaymentService(
- $orderRepository ?? Mockery::mock(OrderRepository::class),
- Mockery::mock(CartRepository::class),
- Mockery::mock(CartTokenService::class),
- Mockery::mock(InvoiceRepository::class),
- Mockery::mock(OrderTransactionRepository::class),
- Mockery::mock(PaymentAttemptRepository::class),
- );
- }
- private function callProtected(PaymentService $service, string $method, array $args)
- {
- $ref = new ReflectionMethod($service, $method);
- $ref->setAccessible(true);
- return $ref->invoke($service, ...$args);
- }
- /**
- * Shape a PayPal capture response the way the SDK would.
- */
- private function paypalCaptureResponse(
- string $orderStatus,
- ?string $captureStatus,
- ?string $amount,
- string $currency = 'USD',
- ): object {
- $captures = [];
- if ($captureStatus !== null) {
- $captures[] = (object) [
- 'id' => 'CAP-123',
- 'status' => $captureStatus,
- 'amount' => (object) [
- 'value' => $amount,
- 'currency_code' => $currency,
- ],
- ];
- }
- return (object) [
- 'statusCode' => 200,
- 'result' => (object) [
- 'id' => 'PAYPAL-ORDER-1',
- 'status' => $orderStatus,
- 'intent' => 'CAPTURE',
- 'purchase_units' => [
- (object) [
- 'amount' => (object) [
- 'value' => $amount,
- 'currency_code' => $currency,
- ],
- 'payments' => (object) ['captures' => $captures],
- ],
- ],
- ],
- ];
- }
- private function fakeOrder(array $attributes = []): object
- {
- return (object) array_merge([
- 'id' => 1,
- 'status' => Order::STATUS_PENDING,
- 'grand_total' => 100.0,
- 'order_currency_code' => 'USD',
- ], $attributes);
- }
- public function test_callback_success_is_idempotent_for_already_processed_order(): void
- {
- $order = $this->fakeOrder(['status' => Order::STATUS_PROCESSING]);
- $orderRepository = Mockery::mock(OrderRepository::class);
- $orderRepository->shouldReceive('find')->once()->with(1)->andReturn($order);
- $input = new PaymentCallbackInput;
- $input->orderId = 1;
- $input->status = 'success';
- $result = $this->makeService($orderRepository)->callback($input);
- $this->assertSame('success', $result['status']);
- $this->assertSame('already_processed', $result['gatewayStatus']);
- $this->assertSame($order, $result['order']);
- }
- public function test_extract_capture_normalizes_response(): void
- {
- $service = $this->makeService();
- $response = $this->paypalCaptureResponse('COMPLETED', 'COMPLETED', '100.00');
- $capture = $this->callProtected($service, 'extractCapture', [$response]);
- $this->assertSame('CAP-123', $capture['transaction_id']);
- $this->assertSame('PAYPAL-ORDER-1', $capture['gateway_order_id']);
- $this->assertSame('COMPLETED', $capture['order_status']);
- $this->assertSame('COMPLETED', $capture['capture_status']);
- $this->assertSame('CAPTURE', $capture['intent']);
- $this->assertSame(100.0, $capture['amount']);
- $this->assertSame('USD', $capture['currency']);
- }
- public function test_assert_amount_matches_passes_on_equal_amount(): void
- {
- $service = $this->makeService();
- $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
- $capture = ['amount' => 100.0, 'currency' => 'USD'];
- $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
- $this->assertTrue(true);
- }
- public function test_assert_amount_matches_throws_on_amount_mismatch(): void
- {
- Event::fake();
- $service = $this->makeService();
- $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
- $capture = ['amount' => 1.0, 'currency' => 'USD'];
- try {
- $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
- $this->fail('Expected OperationFailedException');
- } catch (OperationFailedException $e) {
- $this->assertStringContainsString('does not match', $e->getMessage());
- }
- Event::assertDispatched('bagistoapi.payment.amount-mismatch');
- }
- public function test_assert_amount_matches_throws_on_currency_mismatch(): void
- {
- Event::fake();
- $service = $this->makeService();
- $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
- $capture = ['amount' => 100.0, 'currency' => 'EUR'];
- $this->expectException(OperationFailedException::class);
- $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
- }
- public function test_capture_and_verify_throws_when_gateway_capture_fails(): void
- {
- $smartButton = Mockery::mock(SmartButton::class);
- $smartButton->shouldReceive('captureOrder')->once()->andThrow(new \Exception('network down'));
- $this->app->instance(SmartButton::class, $smartButton);
- $service = $this->makeService();
- $order = $this->fakeOrder();
- $this->expectException(OperationFailedException::class);
- $this->expectExceptionMessage('Gateway capture failed');
- $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
- }
- public function test_capture_and_verify_throws_when_not_completed(): void
- {
- Event::fake();
- $smartButton = Mockery::mock(SmartButton::class);
- $smartButton->shouldReceive('captureOrder')->once()
- ->andReturn($this->paypalCaptureResponse('PENDING', null, '100.00'));
- $this->app->instance(SmartButton::class, $smartButton);
- $service = $this->makeService();
- $order = $this->fakeOrder();
- try {
- $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
- $this->fail('Expected OperationFailedException');
- } catch (OperationFailedException $e) {
- $this->assertStringContainsString('not completed', $e->getMessage());
- }
- Event::assertDispatched('bagistoapi.payment.capture-not-completed');
- }
- public function test_capture_and_verify_returns_normalized_capture_on_success(): void
- {
- $smartButton = Mockery::mock(SmartButton::class);
- $smartButton->shouldReceive('captureOrder')->once()->with('PAYPAL-ORDER-1')
- ->andReturn($this->paypalCaptureResponse('COMPLETED', 'COMPLETED', '100.00'));
- $this->app->instance(SmartButton::class, $smartButton);
- $service = $this->makeService();
- $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
- $capture = $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
- $this->assertSame('CAP-123', $capture['transaction_id']);
- $this->assertSame(100.0, $capture['amount']);
- $this->assertSame('USD', $capture['currency']);
- }
- }
|