diff --git a/CLAUDE.md b/CLAUDE.md index f9279d0..b6d4d91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,12 @@ fetches the route after page load, so the order page never blocks on Quickpay; f inline retry notice (HTTP 502). The controller resolves the api key from the payment's own gateway config and fetches via `Quickpay/ClientFactory`. +`Command/DoctorCommand` (`setono:sylius-quickpay:doctor`) machine-checks the README's Troubleshooting +section per configured gateway: api key ping, private key HMAC self-test (`CallbackValidator`), agreement +existence (`GET agreements/{id}`, failing open on permission errors), order prefix length + cross-gateway +uniqueness, and notify-route registration; `--live` creates a money-less test payment and attempts the +link PUT to surface the missing-permission 403. Non-zero exit on any failed check; warnings don't fail. + `Command/ReconcilePaymentsCommand` (`setono:sylius-quickpay:reconcile-payments`) is the backstop for callbacks that never arrive: `Provider/PendingPaymentProvider` queries non-final Quickpay payments with a `quickpayPaymentId`, the command polls each via `GetHumanStatus` and applies the matching transition diff --git a/README.md b/README.md index 4531bd6..a178fc4 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,24 @@ composer check-style # coding standards For manual testing, use the credit card numbers from the [Quickpay test data](https://learn.quickpay.net/tech-talk/appendixes/test/#test-data). +## Checking your configuration + +The plugin ships a doctor that runs the checks otherwise surfacing as support cases — try it first +when something misbehaves, and after every configuration change: + +```bash +bin/console setono:sylius-quickpay:doctor # read-only +bin/console setono:sylius-quickpay:doctor --live # also probes the payment link permission +``` + +For every configured Quickpay gateway it verifies the api key against Quickpay, self-tests the private +key's checksum computation, checks that a configured agreement id exists on the account, and validates +the order prefix length — plus, across gateways, that no two share a prefix, and that the notify route +is registered at all. With `--live` it also creates a money-less test payment and attempts the payment +link `PUT` the checkout depends on, catching a missing *Create or update payment link* permission before +a customer does (the test payment remains visible on the account; it never carries money). The command +exits non-zero when any check fails, so it can run in CI or cron. + ## Upgrading from 1.x See [UPGRADE-2.0.md](UPGRADE-2.0.md) for the full list of changes an upgrading store has to make — @@ -307,6 +325,8 @@ behavioral changes around refunds and callbacks. ## Troubleshooting +Run [the doctor](#checking-your-configuration) first — it detects every case below. + - `Not authorized: Not authorized to PUT /payments/:id/link` at a `/payment/authorize/...` url: diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 8c177f0..3c5cc5a 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -159,6 +159,10 @@ if you make those and want the store to notice. The README's *Callbacks* section - **New: a reconciliation command.** `setono:sylius-quickpay:reconcile-payments` polls Quickpay for payments stuck in a non-final state — e.g. because a callback never arrived — and applies the matching payment transition. See the README for options and a suggested cron cadence. +- **New: a doctor command.** `setono:sylius-quickpay:doctor` verifies every configured gateway — + api key, private key self-test, agreement existence, order prefix length and uniqueness, and the + notify route — and with `--live` probes the payment link permission. See the README's *Checking + your configuration* section. - **New: Quickpay's fraud signals are surfaced.** The admin operation history shows a *Fraud suspected* badge, the reconciliation command gains a `--fraud-suspected` report mode, and an opt-in `fraud.block_capture` config flag skips the automatic capture on completion for fraud suspected diff --git a/src/Command/DoctorCommand.php b/src/Command/DoctorCommand.php new file mode 100644 index 0000000..f0997aa --- /dev/null +++ b/src/Command/DoctorCommand.php @@ -0,0 +1,360 @@ + $gatewayConfigRepository + */ + public function __construct( + private readonly ClientFactoryInterface $clientFactory, + private readonly RepositoryInterface $gatewayConfigRepository, + private readonly RouterInterface $router, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption( + 'live', + null, + InputOption::VALUE_NONE, + 'Also probe the payment link permission by creating a test payment (leaves a harmless, money-less test payment on the account)', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $live = (bool) $input->getOption('live'); + $this->failures = 0; + + $this->checkNotifyRoute($io); + + /** @var list $gatewayConfigs */ + $gatewayConfigs = $this->gatewayConfigRepository->findBy(['factoryName' => QuickpayGatewayFactory::NAME]); + + if ([] === $gatewayConfigs) { + $this->warn($io, 'No Quickpay payment methods are configured yet — nothing else to check'); + } + + $this->checkDuplicateOrderPrefixes($io, $gatewayConfigs); + + foreach ($gatewayConfigs as $gatewayConfig) { + $io->section(sprintf('Gateway "%s"', (string) $gatewayConfig->getGatewayName())); + + $config = $gatewayConfig->getConfig(); + + $client = $this->checkApiKey($io, $config); + $this->checkPrivateKey($io, $config); + $this->checkAgreement($io, $config, $client); + $this->checkOrderPrefix($io, $config); + + if (null !== $client) { + if ($live) { + $this->checkPaymentLinkPermission($io, $config, $client); + } else { + $this->warn($io, 'Run with --live to probe the payment link permission (creates a harmless test payment)'); + } + } + } + + if ($this->failures > 0) { + $io->error(sprintf('%d check(s) failed.', $this->failures)); + + return Command::FAILURE; + } + + $io->success('All checks passed.'); + + return Command::SUCCESS; + } + + private function checkNotifyRoute(SymfonyStyle $io): void + { + $route = $this->router->getRouteCollection()->get('setono_sylius_quickpay_notify'); + + if (null === $route) { + $this->fail($io, 'The notify route is not registered — import "@SetonoSyliusQuickpayPlugin/Resources/config/routes.yaml" (installation step 4 in the README)'); + + return; + } + + $this->ok($io, sprintf('The notify endpoint for operations made outside the store is registered at "%s"', $route->getPath())); + } + + /** + * Two gateways sharing a prefix on the same account produce the same Quickpay order id for the + * same order, and "order_id already exists" for whichever payment reaches Quickpay second + * + * @param list $gatewayConfigs + */ + private function checkDuplicateOrderPrefixes(SymfonyStyle $io, array $gatewayConfigs): void + { + $byPrefix = []; + foreach ($gatewayConfigs as $gatewayConfig) { + $prefix = self::orderPrefix($gatewayConfig->getConfig()); + if ('' !== $prefix) { + $byPrefix[$prefix][] = (string) $gatewayConfig->getGatewayName(); + } + } + + foreach ($byPrefix as $prefix => $gateways) { + if (\count($gateways) > 1) { + $this->warn($io, sprintf( + 'Gateways %s share the order prefix "%s" — their Quickpay order ids can collide', + implode(', ', array_map(static fn (string $gateway): string => sprintf('"%s"', $gateway), $gateways)), + $prefix, + )); + } + } + } + + /** + * @param array $config + */ + private function checkApiKey(SymfonyStyle $io, array $config): ?ClientInterface + { + $apiKey = ApiKeyResolver::fromGatewayConfig($config); + if (null === $apiKey) { + $this->fail($io, 'No api key is configured'); + + return null; + } + + $client = $this->clientFactory->create($apiKey); + + try { + $client->ping(); + + $this->ok($io, 'The api key is accepted by Quickpay'); + + return $client; + } catch (UnauthorizedException|ForbiddenException) { + // Quickpay answers 401 both for an invalid key and for a valid key whose api user + // lacks the /ping permission (verified live), so fall through to a request every + // integration needs anyway + } catch (\Throwable $e) { + $this->warn($io, sprintf('Could not verify the api key, Quickpay did not answer: %s', $e->getMessage())); + + return null; + } + + try { + $client->payments()->getPage(new PaymentsQuery(pageSize: 1)); + } catch (UnauthorizedException|ForbiddenException) { + $this->fail($io, 'Quickpay rejects the api key — use the API user\'s key (Settings → Users in the Quickpay manager), not a Payment Window agreement\'s'); + + return null; + } catch (\Throwable $e) { + $this->warn($io, sprintf('Could not verify the api key, Quickpay did not answer: %s', $e->getMessage())); + + return null; + } + + $this->ok($io, 'The api key is accepted by Quickpay (verified via /payments — the api user lacks the /ping permission, which is harmless)'); + + return $client; + } + + /** + * @param array $config + */ + private function checkPrivateKey(SymfonyStyle $io, array $config): void + { + // Configurations written by the 1.x form may still carry the old key + $privateKey = $config['private_key'] ?? $config['privatekey'] ?? null; + + if (!is_string($privateKey) || '' === $privateKey) { + $this->fail($io, 'No private key is configured — every callback would be rejected as unsigned'); + + return; + } + + // Exercise the sign/verify path the callbacks depend on; a sign-then-verify roundtrip with + // the same key cannot report false, so the self-test's failure mode is an exception + $validator = new CallbackValidator($privateKey); + $payload = '{"doctor": "self-test"}'; + $validator->isValid($payload, $validator->sign($payload)); + + $this->ok($io, 'The private key signs and verifies callbacks (only a real callback proves it matches the account)'); + } + + /** + * @param array $config + */ + private function checkAgreement(SymfonyStyle $io, array $config, ?ClientInterface $client): void + { + // Configurations written by the 1.x form may still carry the old key + $agreementId = $config['agreement_id'] ?? $config['agreement'] ?? null; + + if (null === $agreementId || '' === $agreementId) { + $this->ok($io, 'No agreement id configured — Quickpay uses the account\'s default Payment Window agreement'); + + return; + } + + if (!is_numeric($agreementId)) { + $this->fail($io, sprintf('The configured agreement id is not a number (got "%s")', get_debug_type($agreementId))); + + return; + } + + if (null === $client) { + $this->warn($io, sprintf('Skipped verifying agreement %d — no usable api key', (int) $agreementId)); + + return; + } + + try { + $client->get(sprintf('agreements/%d', (int) $agreementId)); + } catch (NotFoundException) { + $this->fail($io, sprintf('Agreement %d does not exist on this Quickpay account', (int) $agreementId)); + + return; + } catch (UnauthorizedException|ForbiddenException) { + $this->warn($io, sprintf('Could not verify agreement %d — the api user may not have the /agreements permission', (int) $agreementId)); + + return; + } catch (\Throwable $e) { + $this->warn($io, sprintf('Could not verify agreement %d: %s', (int) $agreementId, $e->getMessage())); + + return; + } + + $this->ok($io, sprintf('Agreement %d exists on the account', (int) $agreementId)); + } + + /** + * @param array $config + */ + private function checkOrderPrefix(SymfonyStyle $io, array $config): void + { + $prefix = self::orderPrefix($config); + + if (\strlen($prefix) > self::MAX_ORDER_PREFIX_LENGTH) { + $this->fail($io, sprintf( + 'The order prefix "%s" is %d characters — keep it to %d or less, or Quickpay rejects the order id at checkout', + $prefix, + \strlen($prefix), + self::MAX_ORDER_PREFIX_LENGTH, + )); + + return; + } + + $this->ok($io, '' === $prefix + ? 'No order prefix configured — make sure order numbers are unique on the Quickpay account' + : sprintf('The order prefix "%s" is within Quickpay\'s length limit', $prefix)); + } + + /** + * The one check that needs a write: create a money-less test payment and attempt the link PUT + * the checkout depends on, surfacing the "Not authorized to PUT /payments/:id/link" case + * before a customer does + * + * @param array $config + */ + private function checkPaymentLinkPermission(SymfonyStyle $io, array $config, ClientInterface $client): void + { + $orderId = self::orderPrefix($config) . 'dr' . bin2hex(random_bytes(3)); + + try { + $payment = $client->payments()->create(new CreatePaymentRequest(orderId: $orderId, currency: 'DKK')); + } catch (UnauthorizedException|ForbiddenException) { + $this->fail($io, 'The api user may not create payments — check the POST permission for /payments (Settings → Users → User permissions)'); + + return; + } catch (\Throwable $e) { + $this->warn($io, sprintf('Could not create a test payment: %s', $e->getMessage())); + + return; + } + + try { + $client->payments()->createLink($payment->id, new CreateLinkRequest(amount: 100)); + $this->ok($io, 'The api user may create payment links (PUT /payments/:id/link)'); + } catch (UnauthorizedException|ForbiddenException) { + $this->fail($io, 'The api user may not create payment links — check the PUT checkbox for "Create or update payment link" (Settings → Users → User permissions)'); + } catch (\Throwable $e) { + $this->warn($io, sprintf('Could not create a payment link: %s', $e->getMessage())); + } + + try { + $client->payments()->deleteLink($payment->id); + } catch (\Throwable) { + // Cleaning up the link is best effort; the test payment stays either way + } + + $this->warn($io, sprintf('Test payment %d (order id "%s") remains on the account — it never carries money', $payment->id, $orderId)); + } + + /** + * @param array $config + */ + private static function orderPrefix(array $config): string + { + $prefix = $config['order_prefix'] ?? null; + + return is_string($prefix) ? $prefix : ''; + } + + private function ok(SymfonyStyle $io, string $message): void + { + $io->writeln(sprintf(' %s', $message)); + } + + private function warn(SymfonyStyle $io, string $message): void + { + $io->writeln(sprintf(' ! %s', $message)); + } + + private function fail(SymfonyStyle $io, string $message): void + { + ++$this->failures; + + $io->writeln(sprintf(' %s', $message)); + } +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index ef6a0ae..c2122f6 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -86,6 +86,14 @@ + + + + + + + + diff --git a/tests/Command/DoctorCommandTest.php b/tests/Command/DoctorCommandTest.php new file mode 100644 index 0000000..90d5ac3 --- /dev/null +++ b/tests/Command/DoctorCommandTest.php @@ -0,0 +1,546 @@ + */ + private ObjectProphecy $clientFactory; + + /** @var ObjectProphecy> */ + private ObjectProphecy $gatewayConfigRepository; + + /** @var ObjectProphecy */ + private ObjectProphecy $router; + + protected function setUp(): void + { + $this->clientFactory = $this->prophesize(ClientFactoryInterface::class); + + /** @var ObjectProphecy> $gatewayConfigRepository */ + $gatewayConfigRepository = $this->prophesize(RepositoryInterface::class); + $this->gatewayConfigRepository = $gatewayConfigRepository; + $this->gatewayConfigRepository->findBy(['factoryName' => QuickpayGatewayFactory::NAME])->willReturn([]); + + $this->router = $this->prophesize(RouterInterface::class); + $this->router->getRouteCollection()->willReturn(self::routes(true)); + } + + /** + * @test + */ + public function it_passes_a_healthy_configuration(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('accepted by Quickpay', $tester->getDisplay()); + self::assertStringContainsString('All checks passed', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_quickpay_rejects_the_api_key(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(401, [], '{"message": "Invalid API key"}'), // ping + new Response(401, [], '{"message": "Invalid API key"}'), // the /payments fallback + ))); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('Quickpay rejects the api key', $tester->getDisplay()); + } + + /** + * Quickpay answers 401 on /ping both for an invalid key and for a valid key whose api user + * lacks the /ping permission (verified live), so the doctor falls back to a /payments read + * + * @test + */ + public function it_accepts_an_api_key_whose_api_user_lacks_the_ping_permission(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(401, [], '{"message": "Invalid API key"}'), // ping without the permission + new Response(200, [], '[]'), // the /payments fallback + ))); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('lacks the /ping permission', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_no_api_key_is_configured(): void + { + $this->configureGateways(['quickpay' => ['private_key' => 'the-private-key', 'order_prefix' => 'qp_']]); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('No api key is configured', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_no_private_key_is_configured(): void + { + $this->configureGateways(['quickpay' => ['api_key' => 'the-api-key', 'order_prefix' => 'qp_']]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('No private key is configured', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_verifies_a_configured_agreement(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig(['agreement_id' => 12345])]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $client->get('agreements/12345')->willReturn(['id' => 12345]); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Agreement 12345 exists', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_the_configured_agreement_does_not_exist(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig(['agreement_id' => 12345])]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $client->get('agreements/12345')->willThrow(new NotFoundException(new Response(404), 'Not found')); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('Agreement 12345 does not exist', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_the_order_prefix_is_too_long(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig(['order_prefix' => 'qp_way_too_long_'])]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('keep it to 11 or less', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_two_gateways_share_an_order_prefix(): void + { + $this->configureGateways([ + 'quickpay_a' => self::healthyConfig(), + 'quickpay_b' => self::healthyConfig(), + ]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('share the order prefix "qp_"', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_the_notify_route_is_not_registered(): void + { + $this->router->getRouteCollection()->willReturn(self::routes(false)); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('The notify route is not registered', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_probes_the_payment_link_permission_in_live_mode(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(200, [], '{}'), // ping + new Response(201, [], (string) json_encode(self::payment())), // create test payment + new Response(200, [], '{"url": "https://payment.quickpay.net/x"}'), // link PUT + new Response(204, [], ''), // link cleanup + ))); + + $tester = $this->executeCommand(['--live' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('may create payment links', $tester->getDisplay()); + self::assertStringContainsString('Test payment 999', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_the_api_user_may_not_create_payment_links(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(200, [], '{}'), // ping + new Response(201, [], (string) json_encode(self::payment())), // create test payment + new Response(403, [], '{"message": "Not authorized"}'), // link PUT rejected + new Response(204, [], ''), // link cleanup attempt + ))); + + $tester = $this->executeCommand(['--live' => true]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('Create or update payment link', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_quickpay_does_not_answer_the_ping(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willThrow(new \RuntimeException('Connection timed out')); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Quickpay did not answer', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_quickpay_does_not_answer_the_payments_fallback(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(401, [], '{"message": "Invalid API key"}'), + new Response(500, [], '{"message": "boom"}'), + ))); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Quickpay did not answer', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_the_configured_agreement_is_not_a_number(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig(['agreement_id' => 'not-a-number'])]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('agreement id is not a number', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_skips_the_agreement_check_without_a_usable_api_key(): void + { + $this->configureGateways(['quickpay' => ['private_key' => 'the-private-key', 'agreement_id' => 12345]]); + + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('Skipped verifying agreement 12345', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_the_agreement_cannot_be_verified(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig(['agreement_id' => 12345])]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $client->get('agreements/12345')->willThrow(new ForbiddenException(new Response(403), 'Not authorized')); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('/agreements permission', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_the_agreement_check_errors(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig(['agreement_id' => 12345])]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $client->get('agreements/12345')->willThrow(new \RuntimeException('Connection timed out')); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Could not verify agreement 12345', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_accepts_an_empty_order_prefix(): void + { + $this->configureGateways(['quickpay' => ['api_key' => 'the-api-key', 'private_key' => 'the-private-key']]); + + $client = $this->prophesize(ClientInterface::class); + $client->ping()->willReturn(true); + $this->clientFactory->create('the-api-key')->willReturn($client->reveal()); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('No order prefix configured', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_fails_when_the_api_user_may_not_create_payments(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(200, [], '{}'), + new Response(403, [], '{"message": "Not authorized"}'), + ))); + + $tester = $this->executeCommand(['--live' => true]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('may not create payments', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_the_test_payment_cannot_be_created(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(200, [], '{}'), + new Response(500, [], '{"message": "boom"}'), + ))); + + $tester = $this->executeCommand(['--live' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Could not create a test payment', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_the_link_probe_errors(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(200, [], '{}'), + new Response(201, [], (string) json_encode(self::payment())), + new Response(500, [], '{"message": "boom"}'), + new Response(204, [], ''), + ))); + + $tester = $this->executeCommand(['--live' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Could not create a payment link', $tester->getDisplay()); + self::assertStringContainsString('Test payment 999', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_ignores_a_failing_link_cleanup(): void + { + $this->configureGateways(['quickpay' => self::healthyConfig()]); + + $this->clientFactory->create('the-api-key')->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient( + new Response(200, [], '{}'), + new Response(201, [], (string) json_encode(self::payment())), + new Response(200, [], '{"url": "https://payment.quickpay.net/x"}'), + new Response(500, [], '{"message": "boom"}'), + ))); + + $tester = $this->executeCommand(['--live' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('may create payment links', $tester->getDisplay()); + } + + /** + * @test + */ + public function it_warns_when_no_quickpay_gateway_is_configured(): void + { + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('No Quickpay payment methods are configured', $tester->getDisplay()); + } + + /** + * @param array> $configs gateway name => gateway config + */ + private function configureGateways(array $configs): void + { + $gatewayConfigs = []; + foreach ($configs as $gatewayName => $config) { + $gatewayConfig = $this->prophesize(GatewayConfigInterface::class); + $gatewayConfig->getGatewayName()->willReturn($gatewayName); + $gatewayConfig->getConfig()->willReturn($config); + $gatewayConfigs[] = $gatewayConfig->reveal(); + } + + $this->gatewayConfigRepository->findBy(['factoryName' => QuickpayGatewayFactory::NAME])->willReturn($gatewayConfigs); + } + + /** + * @param array $overrides + * + * @return array + */ + private static function healthyConfig(array $overrides = []): array + { + return $overrides + [ + 'api_key' => 'the-api-key', + 'private_key' => 'the-private-key', + 'order_prefix' => 'qp_', + ]; + } + + /** + * @return array + */ + private static function payment(): array + { + return [ + 'id' => 999, + 'order_id' => 'qp_dr123456', + 'currency' => 'DKK', + 'state' => 'initial', + 'merchant_id' => 1, + ]; + } + + private static function routes(bool $withNotifyRoute): RouteCollection + { + $routes = new RouteCollection(); + + if ($withNotifyRoute) { + $routes->add('setono_sylius_quickpay_notify', new Route('/payment/quickpay/notify')); + } + + return $routes; + } + + /** + * @param array $input + */ + private function executeCommand(array $input = []): CommandTester + { + $application = new Application(); + $application->add(new DoctorCommand( + $this->clientFactory->reveal(), + $this->gatewayConfigRepository->reveal(), + $this->router->reveal(), + )); + + $tester = new CommandTester($application->find('setono:sylius-quickpay:doctor')); + $tester->execute($input); + + return $tester; + } +} diff --git a/tests/Quickpay/QueuedResponsesHttpClient.php b/tests/Quickpay/QueuedResponsesHttpClient.php new file mode 100644 index 0000000..68e2b0d --- /dev/null +++ b/tests/Quickpay/QueuedResponsesHttpClient.php @@ -0,0 +1,35 @@ + */ + private array $responses; + + public function __construct(ResponseInterface ...$responses) + { + $this->responses = array_values($responses); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $response = array_shift($this->responses); + + if (null === $response) { + throw new \LogicException(sprintf('No response queued for "%s %s"', $request->getMethod(), $request->getUri()->getPath())); + } + + return $response; + } +}