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']); } }