diff --git a/tests/Action/AuthorizeActionTest.php b/tests/Action/AuthorizeActionTest.php index bb18f97..dd8517c 100644 --- a/tests/Action/AuthorizeActionTest.php +++ b/tests/Action/AuthorizeActionTest.php @@ -14,6 +14,8 @@ use ReflectionClass; use ReflectionException; use Setono\Payum\Quickpay\Action\AuthorizeAction; +use Setono\Payum\Quickpay\Api; +use Setono\Quickpay\Client\Client; class AuthorizeActionTest extends ActionTestAbstract { @@ -123,4 +125,142 @@ public function shouldCreatePaymentLinkAndRedirectToIt(): void self::assertTrue($body['auto_capture']); self::assertSame(266017, $body['agreement_id']); } + + /** + * A consumer that routes callbacks itself presets `callback_url` and executes Authorize without + * a token. That path must not need the token factory at all — the action only mints a notify + * token when the request carries a token to mint it from. + * + * @test + */ + public function shouldCreateTheLinkWithoutATokenWhenTheCallbackUrlIsPreset(): void + { + $details = new ArrayObject([ + 'quickpayPaymentId' => 1001, + 'amount' => 100, + 'continue_url' => 'theContinueUrl', + 'cancel_url' => 'theCancelUrl', + 'callback_url' => 'thePresetCallbackUrl', + ]); + + /** @var Authorize $authorize */ + $authorize = new $this->requestClass($details); + + $action = new AuthorizeAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + // Deliberately no setGenericTokenFactory(). + + $this->queueResponse('{"url":"https://payment.quickpay.net/payments/1001/payment-window"}'); + + try { + $action->execute($authorize); + self::fail('An HttpRedirect reply should have been thrown'); + } catch (HttpRedirect $redirect) { + self::assertSame('https://payment.quickpay.net/payments/1001/payment-window', $redirect->getUrl()); + } + + $requests = $this->getRequests(); + self::assertCount(1, $requests); + self::assertSame('thePresetCallbackUrl', $this->decodeBody($requests[0])['callback_url']); + } + + /** + * @test + */ + public function shouldThrowBeforeAnyRequestWhenARequiredDetailIsMissing(): void + { + // No callback_url, and no token to mint one from. + $details = new ArrayObject([ + 'quickpayPaymentId' => 1001, + 'amount' => 100, + 'continue_url' => 'theContinueUrl', + 'cancel_url' => 'theCancelUrl', + ]); + + /** @var Authorize $authorize */ + $authorize = new $this->requestClass($details); + + $action = new AuthorizeAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('callback_url'); + + try { + $action->execute($authorize); + } finally { + self::assertCount(0, $this->getRequests(), 'The link request must not be issued'); + } + } + + /** + * @test + */ + public function shouldThrowWhenQuickpayReturnsNoLinkUrl(): void + { + $details = new ArrayObject([ + 'quickpayPaymentId' => 1001, + 'amount' => 100, + 'continue_url' => 'theContinueUrl', + 'cancel_url' => 'theCancelUrl', + 'callback_url' => 'theCallbackUrl', + ]); + + /** @var Authorize $authorize */ + $authorize = new $this->requestClass($details); + + $action = new AuthorizeAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + + $this->queueResponse('{"url":null}'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('did not return a payment link url'); + + $action->execute($authorize); + } + + /** + * `branding_id` is optional, so if it silently stopped being read the link would simply be + * created without it and Quickpay would fall back to the account default — the same failure mode + * the factory tests guard against for `agreement`. Pin that the option reaches the wire. + * + * @test + */ + public function shouldPassTheBrandingIdToTheLink(): void + { + $api = new Api( + client: new Client('test-apikey', $this->httpClient), + privateKey: 'test-privatekey', + brandingId: 424242, + ); + + $details = new ArrayObject([ + 'quickpayPaymentId' => 1001, + 'amount' => 100, + 'continue_url' => 'theContinueUrl', + 'cancel_url' => 'theCancelUrl', + 'callback_url' => 'theCallbackUrl', + ]); + + /** @var Authorize $authorize */ + $authorize = new $this->requestClass($details); + + $action = new AuthorizeAction(); + $action->setGateway($this->gateway); + $action->setApi($api); + + $this->queueResponse('{"url":"https://payment.quickpay.net/payments/1001/payment-window"}'); + + try { + $action->execute($authorize); + self::fail('An HttpRedirect reply should have been thrown'); + } catch (HttpRedirect) { + } + + self::assertSame(424242, $this->decodeBody($this->getRequests()[0])['branding_id']); + } } diff --git a/tests/Action/NotifyActionTest.php b/tests/Action/NotifyActionTest.php index 4910657..f7e83f7 100644 --- a/tests/Action/NotifyActionTest.php +++ b/tests/Action/NotifyActionTest.php @@ -126,6 +126,57 @@ public function shouldRejectMissingChecksum(): void self::assertCount(0, $this->getRequests(), 'No API call should be made for an unsigned callback'); } + /** + * Symfony's HeaderBag lower-cases header names, and the Symfony bridge is what feeds + * GetHttpRequest in production Sylius/Symfony setups — so the lower-cased spelling is the shape + * the checksum lookup actually meets there. It must match case-insensitively. + * + * @test + */ + public function shouldAcceptALowerCasedChecksumHeader(): void + { + $body = '{"id":1001}'; + $this->httpRequestAction->setHttpRequest($body, [ + strtolower(CallbackValidator::CHECKSUM_HEADER) => hash_hmac('sha256', $body, 'test-privatekey'), + ]); + + // No operations: ConfirmPayment fetches and finds nothing to confirm. + $this->queuePayment(['state' => PaymentState::Initial->value, 'operations' => []]); + + $action = new NotifyAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + + $action->execute($this->notify()); + + $requests = $this->getRequests(); + self::assertCount(1, $requests); + $this->assertRequest($requests[0], 'GET', '#/payments/1001$#'); + } + + /** + * Bridges may expose a header's value as a list. The first entry is the checksum. + * + * @test + */ + public function shouldAcceptAListValuedChecksumHeader(): void + { + $body = '{"id":1001}'; + $this->httpRequestAction->setHttpRequest($body, [ + CallbackValidator::CHECKSUM_HEADER => [hash_hmac('sha256', $body, 'test-privatekey')], + ]); + + $this->queuePayment(['state' => PaymentState::Initial->value, 'operations' => []]); + + $action = new NotifyAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + + $action->execute($this->notify()); + + self::assertCount(1, $this->getRequests()); + } + private function notify(): Notify { return new Notify(new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100])); diff --git a/tests/GatewayIntegrationTest.php b/tests/GatewayIntegrationTest.php new file mode 100644 index 0000000..ac66aa7 --- /dev/null +++ b/tests/GatewayIntegrationTest.php @@ -0,0 +1,206 @@ +httpClient = new MockHttpClient(); + $this->httpRequestAction = new StubGetHttpRequestAction(); + + $this->gateway = (new QuickpayGatewayFactory())->create([ + 'api_key' => 'integration-apikey', + 'private_key' => 'integration-privatekey', + 'order_prefix' => 'it', + 'auto_capture' => true, + 'quickpay.client' => new Client('integration-apikey', $this->httpClient), + 'payum.action.get_http_request' => $this->httpRequestAction, + ]); + } + + /** + * Convert → Authorize → Notify (signed, auto-captures) → GetStatus, all through the + * factory-built gateway, fully offline. + * + * @test + */ + public function shouldRunTheWholeFlowThroughTheFactoryWiredGateway(): void + { + // -- Convert: creates the payment at Quickpay and produces the scalar details. + $payment = new Payment(); + $payment->setNumber('000000000001'); + $payment->setTotalAmount(100); + $payment->setCurrencyCode('DKK'); + + $token = new Token(); + $token->setAfterUrl('https://shop.example/after'); + $token->setGatewayName(QuickpayGatewayFactory::NAME); + + $this->queuePayment(['id' => 2002, 'order_id' => 'it000000000001']); + + $convert = new Convert($payment, 'array', $token); + $this->gateway->execute($convert); + + /** @var array $result */ + $result = $convert->getResult(); + $details = new ArrayObject($result); + + self::assertSame(2002, $details['quickpayPaymentId']); + self::assertSame('https://shop.example/after', $details['continue_url']); + + // -- Authorize: creates the payment link and redirects to the payment window. The callback + // url is preset, so no token factory is involved. + $details['callback_url'] = 'https://shop.example/notify'; + + $this->queueResponse('{"url":"https://payment.quickpay.net/payments/2002/window"}'); + + try { + $this->gateway->execute(new Authorize($details)); + self::fail('An HttpRedirect reply should have been thrown'); + } catch (HttpRedirect $redirect) { + self::assertSame('https://payment.quickpay.net/payments/2002/window', $redirect->getUrl()); + } + + // -- Notify: a signed authorize callback. auto_capture is on and the amount matches, so the + // gateway's own ConfirmPayment routing captures. + $body = '{"id":2002}'; + $this->httpRequestAction->setHttpRequest($body, [ + CallbackValidator::CHECKSUM_HEADER => hash_hmac('sha256', $body, 'integration-privatekey'), + ]); + + $this->queuePayment([ + 'id' => 2002, + 'order_id' => 'it000000000001', + 'state' => PaymentState::New->value, + 'operations' => [$this->operation(OperationType::Authorize, amount: 100)], + ]); + $this->queuePayment([ + 'id' => 2002, + 'order_id' => 'it000000000001', + 'state' => PaymentState::Processed->value, + 'operations' => [ + $this->operation(OperationType::Authorize, amount: 100), + $this->operation(OperationType::Capture, amount: 100), + ], + ]); + + $this->gateway->execute(new Notify($details)); + + // -- GetStatus: the captured payment reports as captured, and the balance is persisted. + $this->queuePayment([ + 'id' => 2002, + 'order_id' => 'it000000000001', + 'state' => PaymentState::Processed->value, + 'balance' => 100, + 'operations' => [ + $this->operation(OperationType::Authorize, amount: 100), + $this->operation(OperationType::Capture, amount: 100), + ], + ]); + + $status = new GetHumanStatus($details); + $this->gateway->execute($status); + + self::assertTrue($status->isCaptured(), 'The payment should report as captured'); + self::assertSame(100, $details['balance']); + + // -- The wire log: exactly the calls the flow implies, in order, authenticated with the + // integration credentials. + $requests = $this->httpClient->getRequests(); + self::assertCount(5, $requests); + + $expected = [ + ['POST', '#/payments$#'], + ['PUT', '#/payments/2002/link$#'], + ['GET', '#/payments/2002$#'], + ['POST', '#/payments/2002/capture$#'], + ['GET', '#/payments/2002$#'], + ]; + + foreach ($expected as $i => [$method, $pathPattern]) { + self::assertSame($method, $requests[$i]->getMethod(), sprintf('Request #%d', $i)); + self::assertMatchesRegularExpression($pathPattern, $requests[$i]->getUri()->getPath(), sprintf('Request #%d', $i)); + self::assertSame( + 'Basic ' . base64_encode(':integration-apikey'), + $requests[$i]->getHeaderLine('Authorization'), + sprintf('Request #%d', $i), + ); + } + } + + private function queueResponse(string $body): void + { + $this->httpClient->addResponse(new Response(200, [], $body)); + } + + /** + * @param array $overrides + */ + private function queuePayment(array $overrides = []): void + { + $this->queueResponse((string) json_encode(array_replace([ + 'id' => 2002, + 'order_id' => 'it000000000001', + 'currency' => 'DKK', + 'merchant_id' => 75015, + 'accepted' => true, + 'test_mode' => true, + 'state' => PaymentState::Initial->value, + 'fee' => null, + 'operations' => [], + ], $overrides), \JSON_THROW_ON_ERROR)); + } + + /** + * @return array + */ + private function operation(OperationType $type, int $amount): array + { + return [ + 'id' => 1, + 'type' => $type->value, + 'amount' => $amount, + 'pending' => false, + 'qp_status_code' => '20000', + ]; + } +}