diff --git a/composer.json b/composer.json index 2d93bb4d..1942f156 100755 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", + "payplug/unified-plugin-core": "dev-develop", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", @@ -57,6 +58,12 @@ "webmozart/assert": "^1.8" }, "prefer-stable": true, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/payplug/unified-plugin-core.git" + } + ], "autoload": { "psr-4": { "PayPlug\\SyliusPayPlugPlugin\\": "src/" diff --git a/config/services.yaml b/config/services.yaml index 7db6a5fe..28fce12c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -10,6 +10,12 @@ services: bind: Psr\Log\LoggerInterface: '@monolog.logger.payplug' + # PRE-3469: explicit alias, matching this file's convention for other interfaces below — + # relying on Symfony to auto-resolve a singly-implemented interface does not work for plain + # autowiring (confirmed: omitting this alias fails container compilation outright, since + # NotifyPaymentRequestHandler's #[AsMessageHandler] service is always instantiated). + PayplugUnifiedCore\Contracts\IOrderStateMutator: '@PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PayplugOrderStateMutator' + PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface: class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository parent: sylius.repository.payment diff --git a/migrations/Version20260720100000.php b/migrations/Version20260720100000.php new file mode 100644 index 00000000..bbe438c3 --- /dev/null +++ b/migrations/Version20260720100000.php @@ -0,0 +1,62 @@ +skipIf( + 'test' !== ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null), + 'PRE-3469 spike-only schema, not applied outside the test environment.', + ); + + $this->addSql('CREATE TABLE payplug_operation ( + id INT AUTO_INCREMENT NOT NULL, + operation_id VARCHAR(255) NOT NULL, + exec_code VARCHAR(255) NOT NULL, + outcome VARCHAR(255) NOT NULL, + amount INT NOT NULL, + order_id VARCHAR(255) NOT NULL, + treated TINYINT(1) NOT NULL, + treated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', + created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', + UNIQUE INDEX payplug_operation_id_unique (operation_id), + INDEX payplug_operation_order_id_idx (order_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + $this->skipIf( + 'test' !== ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null), + 'PRE-3469 spike-only schema, not applied outside the test environment.', + ); + + $this->addSql('DROP TABLE payplug_operation'); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b01264ac..7dc60db7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,12 +7,16 @@ tests/PHPUnit + + tests/PHPUnit/Spike/SpikeIntegrationTest.php - + diff --git a/src/Command/Handler/NotifyPaymentRequestHandler.php b/src/Command/Handler/NotifyPaymentRequestHandler.php index 9ea5147f..55fe5add 100644 --- a/src/Command/Handler/NotifyPaymentRequestHandler.php +++ b/src/Command/Handler/NotifyPaymentRequestHandler.php @@ -6,10 +6,14 @@ use Payplug\Resource\Payment; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Command\NotifyPaymentRequest; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier; +use PayplugUnifiedCore\Contracts\IOrderStateMutator; +use PayplugUnifiedCore\Models\PaymentOutcome; +use Psr\Log\LoggerInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface; use Sylius\Component\Core\Model\PaymentInterface; @@ -19,6 +23,21 @@ #[AsMessageHandler] class NotifyPaymentRequestHandler { + /** + * PRE-3469: additive translation from the PayPlug status vocabulary (the same one + * PaymentTransitionApplier already maps from) to UPC's PaymentOutcome vocabulary, for the + * real IOrderStateMutator call below. Statuses with no PaymentOutcome equivalent + * (e.g. STATUS_ABORTED/STATUS_CANCELED*, which PaymentTransitionApplier maps to + * TRANSITION_CANCEL) are intentionally absent here — they're skipped, never force-mapped. + * + * @var array + */ + private const STATUS_TO_OUTCOME = [ + PayPlugApiClientInterface::STATUS_CAPTURED => PaymentOutcome::PAID, + PayPlugApiClientInterface::STATUS_AUTHORIZED => PaymentOutcome::AUTHORIZED, + PayPlugApiClientInterface::FAILED => PaymentOutcome::FAILED, + ]; + public function __construct( private PaymentRequestProviderInterface $paymentRequestProvider, private StateMachineInterface $stateMachine, @@ -26,6 +45,8 @@ public function __construct( private PaymentNotificationHandler $paymentNotificationHandler, private RefundNotificationHandler $refundNotificationHandler, private PaymentTransitionApplier $paymentTransitionApplier, + private IOrderStateMutator $orderStateMutator, + private LoggerInterface $logger, ) {} public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void @@ -67,6 +88,7 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void $payment->setDetails($details->getArrayCopy()); if ($resource instanceof Payment) { $this->paymentTransitionApplier->apply($payment); + $this->applyOrderStateMutator($payment); } $this->stateMachine->apply( @@ -85,4 +107,44 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void ); } } + + /** + * PRE-3469: additive real call site for PayplugOrderStateMutator. PaymentTransitionApplier + * has already applied the real transition above by the time this runs, so the mutator's own + * can()-guard makes this a no-op in the normal case — this exists to prove the contract works + * against a live webhook event, not to change behavior. Any failure here is caught and + * logged, never allowed to affect the primary notification flow above. + */ + private function applyOrderStateMutator(PaymentInterface $payment): void + { + $details = $payment->getDetails(); // @phpstan-ignore-line - getDetails() return mixed + $status = $details['status'] ?? ''; + $outcome = self::STATUS_TO_OUTCOME[$status] ?? null; + if (null === $outcome) { + return; + } + + $order = $payment->getOrder(); + if (null === $order) { + return; + } + + // PayplugOrderStateMutator resolves the order's *last* payment internally (its contract + // is keyed by order ID, not payment ID). On a multi-payment order — e.g. a failed attempt + // followed by a retry — that could be a different payment than the one this webhook is + // actually about. Skip rather than risk transitioning the wrong payment. + if ($order->getLastPayment()?->getId() !== $payment->getId()) { + return; + } + + try { + $this->orderStateMutator->apply((string) $order->getId(), $outcome); // @phpstan-ignore-line - ResourceInterface::getId() return mixed + } catch (\Throwable $e) { + $this->logger->warning('[PayPlug] PayplugOrderStateMutator additive call failed.', [ + 'sylius_payment_id' => $payment->getId(), + 'outcome' => $outcome, + 'exception' => $e->getMessage(), + ]); + } + } } diff --git a/src/ConfigurationRepository/PayplugConfigurationRepository.php b/src/ConfigurationRepository/PayplugConfigurationRepository.php new file mode 100644 index 00000000..5a207004 --- /dev/null +++ b/src/ConfigurationRepository/PayplugConfigurationRepository.php @@ -0,0 +1,123 @@ +activeClientConfig(); + $value = $client[$key] ?? null; + + return \is_string($value) ? $value : null; + } + + public function set(string $key, string $value): void + { + $config = $this->gatewayConfig->getConfig(); + $scope = $this->activeScope($config); + $client = $config[$scope] ?? []; + if (!\is_array($client)) { + $client = []; + } + $client[$key] = $value; + $config[$scope] = $client; + + // Persisting $config is the caller's responsibility (Doctrine flush), same as every + // other GatewayConfigInterface mutation in this plugin (see UnifiedAuthenticationController). + $this->gatewayConfig->setConfig($config); + } + + public function getClientId(): string + { + return $this->requireString('client_id'); + } + + public function getClientSecret(): string + { + return $this->requireString('client_secret'); + } + + public function getPublicKeyId(): string + { + return $this->get('public_key_id') ?? ''; + } + + public function getPublicKeyValue(): string + { + return $this->get('public_key_value') ?? ''; + } + + private function requireString(string $key): string + { + $value = $this->get($key); + if (null === $value || '' === $value) { + // Never interpolate the resolved *value* here, only the key name and factory name. + throw new ApiException(\sprintf( + 'Missing "%s" in gateway configuration "%s".', + $key, + $this->gatewayConfig->getFactoryName() ?? 'unknown', + )); + } + + return $value; + } + + /** + * @return array + */ + private function activeClientConfig(): array + { + $config = $this->gatewayConfig->getConfig(); + $client = $config[$this->activeScope($config)] ?? []; + if (!\is_array($client)) { + return []; + } + + /** @var array $typedClient */ + $typedClient = $client; + + return $typedClient; + } + + /** + * @param array $config + */ + private function activeScope(array $config): string + { + return true === ($config['live'] ?? false) ? 'live_client' : 'test_client'; + } +} diff --git a/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php b/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php index d22ef359..e3aea36d 100644 --- a/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php +++ b/src/DependencyInjection/PayPlugSyliusPayPlugExtension.php @@ -33,6 +33,35 @@ public function prepend(ContainerBuilder $container): void $this->prependTwigExtension($container); $this->prependDoctrineMigrations($container); $this->prependMonologExtension($container); + $this->prependSpikeDoctrineMapping($container); + } + + /** + * PRE-3469 spike only — registers src/Spike/Entity as a plain (non-Sylius-resource) + * Doctrine mapping so the spike's integration test can persist PayplugOperation for real. + * Not a `sylius_resource` on purpose: that would pull in grids/forms/routes this throwaway + * entity has no use for. Restricted to the `test` environment on purpose too — this is + * test-only scaffolding, it must never register Doctrine metadata for a throwaway entity in + * prod. Remove this method along with src/Spike/ once the spike is closed. + */ + private function prependSpikeDoctrineMapping(ContainerBuilder $container): void + { + if (!$container->hasExtension('doctrine') || 'test' !== $container->getParameter('kernel.environment')) { + return; + } + + $container->prependExtensionConfig('doctrine', [ + 'orm' => [ + 'mappings' => [ + 'PayPlugSyliusPayPlugPluginSpike' => [ + 'type' => 'attribute', + 'dir' => dirname(__DIR__) . '/Spike/Entity', + 'prefix' => 'PayPlug\SyliusPayPlugPlugin\Spike\Entity', + 'is_bundle' => false, + ], + ], + ], + ]); } private function prependTwigExtension(ContainerBuilder $container): void diff --git a/src/PaymentProcessing/PayplugOrderStateMutator.php b/src/PaymentProcessing/PayplugOrderStateMutator.php new file mode 100644 index 00000000..ebf0a688 --- /dev/null +++ b/src/PaymentProcessing/PayplugOrderStateMutator.php @@ -0,0 +1,78 @@ + + */ + private const OUTCOME_TO_TRANSITION = [ + PaymentOutcome::PAID => PaymentTransitions::TRANSITION_COMPLETE, + PaymentOutcome::AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, + PaymentOutcome::CAPTURE_REQUIRED => PaymentTransitions::TRANSITION_PROCESS, + PaymentOutcome::REFUNDED => PaymentTransitions::TRANSITION_REFUND, + PaymentOutcome::FAILED => PaymentTransitions::TRANSITION_FAIL, + ]; + + public function __construct( + private readonly OrderRepositoryInterface $orderRepository, + private readonly StateMachineInterface $stateMachine, + private readonly EntityManagerInterface $entityManager, + ) { + } + + public function apply(string $orderId, string $outcome): void + { + if (PaymentOutcome::THREE_DS_PENDING === $outcome) { + return; + } + + $transition = self::OUTCOME_TO_TRANSITION[$outcome] ?? null; + if (null === $transition) { + throw new \InvalidArgumentException(\sprintf('No Symfony Workflow transition mapped for PaymentOutcome "%s".', $outcome)); + } + + $order = $this->orderRepository->findOrderById($orderId); + if (null === $order) { + throw new PaymentNotFoundException(\sprintf('No order found for id "%s".', $orderId)); + } + + $payment = $order->getLastPayment(); + if (null === $payment) { + throw new PaymentNotFoundException(\sprintf('Order "%s" has no payment to mutate.', $orderId)); + } + + // Guarded exactly like the plugin's existing PaymentStateResolver::applyTransition(): + // an out-of-order webhook retry landing on a payment already past this transition is a + // silent no-op rather than a thrown workflow exception. + if ($this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition); + $this->entityManager->flush(); + } + } +} diff --git a/src/Spike/Entity/PayplugOperation.php b/src/Spike/Entity/PayplugOperation.php new file mode 100644 index 00000000..3f1f5c84 --- /dev/null +++ b/src/Spike/Entity/PayplugOperation.php @@ -0,0 +1,116 @@ +operationId = $operationId; + $this->execCode = $execCode; + $this->outcome = $outcome; + $this->amount = $amount; + $this->orderId = $orderId; + $this->createdAt = new \DateTimeImmutable(); + } + + public static function fromOperationData(OperationData $operationData): self + { + return new self( + $operationData->operationId, + $operationData->execCode, + $operationData->outcome, + $operationData->amount, + $operationData->orderId, + ); + } + + public function updateFromOperationData(OperationData $operationData): void + { + $this->execCode = $operationData->execCode; + $this->outcome = $operationData->outcome; + $this->amount = $operationData->amount; + } + + public function toOperationData(): OperationData + { + return new OperationData($this->operationId, $this->execCode, $this->outcome, $this->amount, $this->orderId); + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getOrderId(): string + { + return $this->orderId; + } + + public function isTreated(): bool + { + return $this->treated; + } + + public function markTreated(): void + { + $this->treated = true; + $this->treatedAt = new \DateTimeImmutable(); + } +} diff --git a/src/Spike/SyliusPaymentRepository.php b/src/Spike/SyliusPaymentRepository.php new file mode 100644 index 00000000..42f1b0ca --- /dev/null +++ b/src/Spike/SyliusPaymentRepository.php @@ -0,0 +1,80 @@ +requireOperation(['orderId' => $orderId], \sprintf('No operation for order "%s".', $orderId))->toOperationData(); + } + + public function getByOperationId(string $operationId): OperationData + { + return $this->requireOperation(['operationId' => $operationId], \sprintf('No operation "%s".', $operationId))->toOperationData(); + } + + public function save(OperationData $operationData): void + { + $existing = $this->findOperation(['operationId' => $operationData->operationId]); + + if (null === $existing) { + $this->entityManager->persist(PayplugOperation::fromOperationData($operationData)); + } else { + $existing->updateFromOperationData($operationData); + } + + $this->entityManager->flush(); + } + + public function markTreated(string $operationId): void + { + $operation = $this->requireOperation(['operationId' => $operationId], \sprintf('No operation "%s".', $operationId)); + $operation->markTreated(); + $this->entityManager->flush(); + } + + public function isTreated(string $operationId): bool + { + $operation = $this->findOperation(['operationId' => $operationId]); + + return null !== $operation && $operation->isTreated(); + } + + /** + * @param array $criteria + */ + private function requireOperation(array $criteria, string $notFoundMessage): PayplugOperation + { + $operation = $this->findOperation($criteria); + if (null === $operation) { + throw new PaymentNotFoundException($notFoundMessage); + } + + return $operation; + } + + /** + * @param array $criteria + */ + private function findOperation(array $criteria): ?PayplugOperation + { + return $this->entityManager->getRepository(PayplugOperation::class)->findOneBy($criteria); + } +} diff --git a/src/Spike/VALIDATION.md b/src/Spike/VALIDATION.md new file mode 100644 index 00000000..1d952226 --- /dev/null +++ b/src/Spike/VALIDATION.md @@ -0,0 +1,188 @@ +# PRE-3469 — Validation des contrats UPC contre Sylius + +`IOrderStateMutator`, `ITokenCache` et `IConfigurationRepository` ont été promus en implémentations +réelles, branchées dans le code de production existant (voir `src/PaymentProcessing/PayplugOrderStateMutator.php`, +`src/TokenCache/PayplugTokenCache.php`, `src/ConfigurationRepository/PayplugConfigurationRepository.php`). +`IPaymentRepository` reste hors scope de ce ticket (lié à un Value Object fortement couplé à +l'API Unifiée, non encore branché sur le plugin) — son squelette de preuve reste dans ce dossier, +voir `SyliusPaymentRepository.php`. + +Couverture de tests, 3 niveaux : + +- **Unitaire** (`tests/PHPUnit/{PaymentProcessing,TokenCache,ConfigurationRepository}/*Test.php` + + `tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php`) : mocks natifs PHPUnit + sur chaque collaborateur. +- **Intégration réelle** (`SpikeIntegrationTest.php`, toujours dans ce dossier car c'est le seul + endroit du plugin qui boote un vrai kernel Sylius pour les tests) : kernel Sylius réellement + booté, vraie base MariaDB jetable, vrai state machine Symfony Workflow, vrai pool de cache + PSR-6, vraie persistance Doctrine — aucun mock. Couvre les 4 contrats, y compris + `IPaymentRepository`. +- Les deux suites tournent avec `composer install`, `vendor/bin/phpunit` une fois la base de + test créée — voir section Câblage. + +**Verdict global : aucune friction bloquante restante.** Une friction bloquante a été trouvée et +corrigée pendant ce rework (voir « Friction bloquante corrigée » ci-dessous), plus deux notes non +bloquantes (aucune ne remet en cause la forme des interfaces). + +## Friction bloquante corrigée : `payplug/unified-plugin-core` en `require-dev` + +`SyliusOrderStateMutator`/`SyliusTokenCache`/`SyliusConfigurationRepository` (les squelettes +d'origine) implémentaient directement les interfaces UPC depuis `src/Spike/`, avec +`payplug/unified-plugin-core` en `require-dev` uniquement. Tant que ce code restait isolé et +jamais référencé par une classe de production, ça ne posait pas de problème. Mais brancher +`PayplugOrderStateMutator` dans `NotifyPaymentRequestHandler` — une classe systématiquement +chargée, y compris en production — change la donne : un vrai `composer install --no-dev` chez un +marchand n'installerait pas `unified-plugin-core`, et charger une classe qui `implements` une +interface inexistante est une erreur PHP fatale, pas un échec silencieux. + +**Fix** : `payplug/unified-plugin-core` est passé en dépendance `require` (toujours pinné sur +`dev-develop`, même repository VCS). Documenté ici plutôt que traité comme un simple détail de +`composer.json`, parce que ça anticipe une partie de ce que PRE-3563 (la vraie dépendance de +production pour l'OAuth) devait poser — voir section Câblage plus bas pour ce qui reste à faire +pour PRE-3563. + +## IOrderStateMutator — réel : `PayplugOrderStateMutator.php` + +Mapping validé : `PaymentOutcome::PAID/AUTHORIZED/CAPTURE_REQUIRED/REFUNDED/FAILED` → +`PaymentTransitions::TRANSITION_COMPLETE/AUTHORIZE/PROCESS/REFUND/FAIL` sur le graphe +`sylius_payment`, via `StateMachineInterface::can()/apply()` — exactement le pattern déjà utilisé +par `PaymentStateResolver::applyTransition()` dans le plugin actuel. + +`THREE_DS_PENDING` confirmé sans transition : l'implémentation retourne immédiatement sans +toucher au state machine, la commande reste `new` jusqu'au webhook — conforme au résultat attendu +du ticket. + +**Branché en production** : appel additif dans `NotifyPaymentRequestHandler`, juste après le +`PaymentTransitionApplier::apply($payment)` existant, une fois la transition déjà appliquée par +ce dernier. Le statut PayPlug déjà connu à cet endroit (`$payment->getDetails()['status']`) est +traduit en `PaymentOutcome` (seuls `STATUS_CAPTURED`/`STATUS_AUTHORIZED`/`FAILED` ont un +équivalent — `STATUS_ABORTED`/`STATUS_CANCELED*`, mappés par `PaymentTransitionApplier` sur +`TRANSITION_CANCEL`, n'ont pas d'équivalent `PaymentOutcome` et sont donc volontairement ignorés, +pas forcés). L'appel est protégé par un `try/catch` qui journalise sans jamais propager : la +transition ayant déjà été appliquée par `PaymentTransitionApplier`, le garde-fou `can()` du +mutateur en fait un no-op silencieux dans le cas normal ; ce câblage prouve que le contrat +fonctionne contre un webhook réel sans remplacer ni risquer le flux existant. + +**Note (non bloquante)** : le contrat prend un `orderId`, mais la transition Symfony Workflow vit +sur le sous-objet `Payment` de la commande (`Order::getLastPayment()`), pas sur l'`Order` +lui-même. Un hop supplémentaire Order → Payment est donc nécessaire côté adaptateur Sylius — ce +que WooCommerce n'aurait pas besoin de faire. C'est le bon endroit pour cette différence (dans +l'adaptateur, pas dans le contrat CMS-agnostique). + +**Note annexe (mécanique, pas conceptuelle)** : le plugin a aujourd'hui deux chemins de +production distincts qui appliquent des transitions Symfony Workflow sur un paiement — +`PaymentTransitionApplier` (webhooks/status, celui utilisé ci-dessus) et `PaymentStateResolver` +(réconciliation CLI, `payplug:update-payment-state`). Ce ticket ne les unifie pas ; ça reste deux +implémentations parallèles du même idiome `can()`/`apply()`. + +## ITokenCache — réel : `PayplugTokenCache.php` + +Aucune friction. `get`/`set`/`delete` se posent 1:1 sur `Psr\Cache\CacheItemPoolInterface::getItem/ +save/deleteItem`, exactement comme le docblock de l'interface le prévoyait déjà. Le pool par +défaut de Sylius (`cache.app`, `Symfony\Component\Cache\Adapter\AdapterInterface`) satisfait déjà +`CacheItemPoolInterface`, aliasé nativement par FrameworkBundle — filesystem, APCu ou Redis selon +le déploiement, sans changement de code côté `PayplugTokenCache`. + +**Cible confirmée par le ticket** : `ITokenCache` cible le cache du token JWT OAuth +(authentification via le SDK PayPlug), pas le stockage carte/one-click — la sauvegarde de carte +est une entité Doctrine permanente (`Card`), sans aucun cache impliqué. + +**Validé par un test d'intégration réel, pas par un appel de production** : le seul analogue de +production existant, `PayPlugApiClientFactory::getTokenForGatewayConfig()`, met en cache le token +OAuth qui conditionne l'authentification de *toutes* les gateways du plugin. Remplacer sa logique +de cache inline aurait un risque de régression disproportionné par rapport à l'objectif de +validation de ce ticket ; `PayplugTokenCache` reste donc validé par un test d'intégration contre +un vrai pool PSR-6 (`SpikeIntegrationTest::testTokenCache_realCachePool_roundTripsThroughRealAdapter`), +sans point d'entrée de production. + +## IConfigurationRepository — réel : `PayplugConfigurationRepository.php` + +`GatewayConfigInterface::getConfig()` scope les credentials par `PaymentMethod` *et* par mode +live/test (`config['live_client']` vs `config['test_client']`, sélectionné par `config['live']` — +même pattern que `PayPlugApiClientFactory::getTokenForGatewayConfig()` existant). Une instance de +`PayplugConfigurationRepository` doit donc être construite par `GatewayConfigInterface` (donc par +`PaymentMethod`), pas partagée comme service unique — c'est une factory, pas un singleton. Ne +remet pas en cause le contrat, mais à garder en tête si le futur client Unified API suppose "un +repository = un marchand". + +**Point positif** : Sylius expose déjà un `GatewayConfigEncrypter` (expérimental) qui chiffre au +repos l'intégralité du tableau `getConfig()` — si branché, `CLIENT_SECRET` en bénéficie +gratuitement. Ce que `PayplugConfigurationRepository` doit garantir lui-même, c'est qu'un secret +déchiffré ne fuite jamais dans un message de log ou d'exception : `requireString()` n'interpole +jamais que le nom de la clé, jamais sa valeur. + +**Friction non bloquante, résolue par un choix explicite** : `getPublicKeyId()`/ +`getPublicKeyValue()` n'ont aucun équivalent de production — le plugin n'a aucune notion de clé +publique Hosted Fields aujourd'hui (`grep` sur `public_key`/`PUBLIC_KEY` dans `src/` ne retourne +rien en dehors de ce fichier). Plutôt que de lever une exception comme `getClientId()`/ +`getClientSecret()`, ces deux méthodes renvoient une chaîne vide tant qu'aucun code de production +n'écrit ces clés — le contrat reste implémentable de bout en bout, prêt pour quand Hosted Fields +sera construit. + +## IPaymentRepository — hors scope, squelette inchangé : `SyliusPaymentRepository.php` + `Entity/PayplugOperation.php` + +Hors scope de ce ticket (lié à un Value Object fortement couplé à l'API Unifiée, non encore +branché sur le plugin) — reste un squelette de preuve, inchangé depuis la version précédente de +ce document. + +**Constat principal du ticket, confirmé** : le plugin actuel ne stocke pas `OperationData` de +façon normalisée. L'id Payplug est aujourd'hui écrit dans le JSON de `Payment::details` et +retrouvé via `LIKE '%id%'` (`PaymentRepository::findOneByPayPlugPaymentId`). Ça fonctionne pour ce +seul lookup, mais ne peut pas porter `markTreated()`/`isTreated()` (il faut un flag d'idempotence +indexé) ni `getByOperationId()` proprement (il faut une colonne indexée, pas une recherche de +sous-chaîne dans un blob sérialisé). + +Le squelette introduit donc une nouvelle table `payplug_operation`, sans aucune dépendance au SDK +`payplug/payplug-php` — juste `OperationData` en entrée/sortie. C'est un vrai changement de schéma +(nouvelle table), pas une extension de l'existant — à traiter comme tel dans le chiffrage du futur +ticket de production. + +**Note annexe (mécanique, pas conceptuelle)** : `PayplugOperation` vit hors de `src/Entity/` et +n'est pas un `sylius_resource` dans `config/resources.yaml` (contrairement à `Card` et +`RefundHistory`) — enregistrer un `sylius_resource` a été essayé pour le test d'intégration mais +échoue tant que la classe n'implémente pas `Sylius\Resource\Model\ResourceInterface` (grilles/ +formulaires/routes Sylius, inutiles pour cette entité de pure persistance). Le mapping Doctrine +réel utilisé pour le test d'intégration passe donc par un `doctrine.orm.mappings` prepend dans +`PayPlugSyliusPayPlugExtension::prependSpikeDoctrineMapping()` — méthode explicitement marquée +PRE-3469-only, restreinte à `kernel.environment === 'test'` (jamais en prod), à retirer avec +`src/Spike/` si le spike est abandonné. + +## Câblage (pour rejouer les tests) + +- `composer.json` : `repositories` avec un repository `vcs` vers + `https://github.com/payplug/unified-plugin-core.git` + `payplug/unified-plugin-core: + "dev-develop"` désormais en dépendance `require` (voir « Friction bloquante corrigée » + ci-dessus) — fonctionne pour n'importe qui ayant accès au repo GitHub (même accès que pour ce + repo), et en CI via l'étape `Composer - Github Auth` déjà configurée dans + `payplug/template-ci`. Une première version utilisait un repository `path` local + (`../../unified-plugin-core`) : ça ne marche que sur une machine avec les deux repos clonés + côte à côte, cassait `composer install` pour tout le monde d'autre — corrigé. Nécessite que le + pin exact `symfony/polyfill-mbstring: 1.28.0` d'UPC ait été relâché en `^1.28` (corrigé dans + unified-plugin-core suite à ce spike — il bloquait tout `composer install` aux côtés de + `sylius/sylius ^2.0`, qui exige `^1.31`). Contrepartie connue : ça ajoute une résolution réseau + vers GitHub à chaque install (léger coût CI/dev) — pas d'alternative disponible ici (pas de + Packagist privé configuré dans cet org pour `payplug/unified-plugin-core`, et un repository + `path` est exclu pour la raison ci-dessus). Ce compromis disparaît quand PRE-3563 posera la + vraie dépendance de production définitive (OAuth réel contre UPC). +- `phpunit.xml.dist` : `KERNEL_CLASS_PATH` renommé en `KERNEL_CLASS` — la variable que Symfony lit + réellement pour `KernelTestCase::bootKernel()`. Sans ce fix, aucun test à base de kernel + (fonctionnel/intégration) n'a jamais pu tourner dans ce repo — un bug de config préexistant, + invisible tant qu'aucun test de ce type n'existait. +- `tests/TestApplication/.env.test.local` (gitignored) : `DATABASE_URL` pointée vers un conteneur + MariaDB jetable (`docker run --name payplug-pre3469-mysql -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p + 3309:3306 mariadb:latest`) — le MySQL système de la machine refuse `root@127.0.0.1` sans mot de + passe. `.env.local` seul ne suffit pas : Symfony l'ignore délibérément quand `APP_ENV=test` + (reproductibilité des tests) — il faut `.env.test.local` spécifiquement. +- Base créée/migrée avec les commandes standard du Makefile (`doctrine:database:create`, + `doctrine:migration:migrate`, `sylius:payment:generate-key`, `sylius:fixtures:load`) — la table + `payplug_operation` est créée automatiquement par `migrations/Version20260720100000.php`, plus + besoin d'aucune étape manuelle. + +**Isolation des tests** : `SpikeIntegrationTest` ne tourne dans aucune transaction annulée en fin +de test (pas de `DAMADoctrineTestBundle`). Une première version cherchait un paiement de fixture +déjà à l'état `new` — ça fonctionne une fois, mais une commande réelle ne repasse jamais à `new` +une fois transitionnée, donc ce pool fini s'épuise au fil des ré-exécutions contre la même base +jetable. Corrigé en créant un `Payment` frais sur une commande de fixture existante à chaque test +(`createOrderWithFreshPayment()`) plutôt que d'en chercher un dans un état donné — vérifié stable +sur 3 exécutions consécutives. Une vraie suite (non-spike) voudrait un rollback transactionnel +entre tests plutôt que ce contournement. diff --git a/src/TokenCache/PayplugTokenCache.php b/src/TokenCache/PayplugTokenCache.php new file mode 100644 index 00000000..815cb970 --- /dev/null +++ b/src/TokenCache/PayplugTokenCache.php @@ -0,0 +1,55 @@ + cache.app alias) already satisfies + * CacheItemPoolInterface, regardless of whether that pool is backed by the filesystem, APCu, or + * Redis in a given deployment. This class is validated by a real-infrastructure integration + * test (see SpikeIntegrationTest) rather than wired into a live request path: the one existing + * production caller of an OAuth token cache, PayPlugApiClientFactory::getTokenForGatewayConfig(), + * gates authentication for every PayPlug gateway — replacing its inline caching logic here would + * carry more regression risk than this ticket's validation goal justifies. + */ +final class PayplugTokenCache implements ITokenCache +{ + public function __construct(private readonly CacheItemPoolInterface $cache) + { + } + + public function get(string $key): ?string + { + $item = $this->cache->getItem($key); + if (!$item->isHit()) { + return null; + } + + $value = $item->get(); + + return \is_string($value) ? $value : null; + } + + public function set(string $key, string $value, int $ttlSeconds): void + { + $item = $this->cache->getItem($key); + $item->set($value); + $item->expiresAfter($ttlSeconds); + $this->cache->save($item); + } + + public function delete(string $key): void + { + $this->cache->deleteItem($key); + } +} diff --git a/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php new file mode 100644 index 00000000..257a9157 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/NotifyPaymentRequestHandlerTest.php @@ -0,0 +1,215 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class); + $this->refundNotificationHandler = $this->createMock(RefundNotificationHandler::class); + $this->paymentTransitionApplier = $this->createMock(PaymentTransitionApplier::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new NotifyPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->apiClientFactory, + $this->paymentNotificationHandler, + $this->refundNotificationHandler, + $this->paymentTransitionApplier, + $this->orderStateMutator, + $this->logger, + ); + } + + public function testInvoke_knownStatus_callsOrderStateMutatorWithMappedOutcome(): void + { + $this->prepareNormalFlow(PayPlugApiClientInterface::STATUS_CAPTURED, 42); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->__invoke(new NotifyPaymentRequest('hash')); + } + + public function testInvoke_unknownStatus_doesNotCallOrderStateMutator(): void + { + $this->prepareNormalFlow(PayPlugApiClientInterface::STATUS_CANCELED, 42); + + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->__invoke(new NotifyPaymentRequest('hash')); + } + + public function testInvoke_orderStateMutatorThrows_isCaughtAndDoesNotPreventCompletion(): void + { + $this->prepareNormalFlow(PayPlugApiClientInterface::STATUS_CAPTURED, 42); + + $this->orderStateMutator->method('apply')->willThrowException(new \RuntimeException('boom')); + $this->logger->expects(self::once())->method('warning'); + + // The outer flow must still complete normally: TRANSITION_COMPLETE, not TRANSITION_FAIL. + $this->stateMachine->expects(self::once())->method('apply')->with( + self::isInstanceOf(PaymentRequestInterface::class), + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + $this->handler->__invoke(new NotifyPaymentRequest('hash')); + } + + public function testInvoke_paymentAlreadyCompleted_shortCircuitsAndSkipsOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getState')->willReturn(PaymentInterface::STATE_COMPLETED); + $method = $this->createMock(PaymentMethodInterface::class); + $payment->method('getMethod')->willReturn($method); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '{}']]); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $client = $this->createMock(PayPlugApiClientInterface::class); + $resource = $this->createMock(PayplugResourcePayment::class); + $client->method('treat')->willReturn($resource); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client); + + $this->paymentTransitionApplier->expects(self::never())->method('apply'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_COMPLETE, + ); + + $this->handler->__invoke(new NotifyPaymentRequest('hash')); + } + + public function testInvoke_invalidPayload_failsWithoutCallingOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '']]); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $paymentRequest->expects(self::once())->method('setResponseData'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->stateMachine->expects(self::once())->method('apply')->with( + $paymentRequest, + PaymentRequestTransitions::GRAPH, + PaymentRequestTransitions::TRANSITION_FAIL, + ); + + $this->handler->__invoke(new NotifyPaymentRequest('hash')); + } + + /** + * Regression test for a multi-payment order: PayplugOrderStateMutator resolves the order's + * *last* payment internally (its contract is keyed by order ID, not payment ID). If a + * different payment (e.g. an earlier failed attempt) is the order's last payment, the + * additive call must be skipped rather than risk transitioning the wrong payment. + */ + public function testInvoke_paymentIsNotOrdersLastPayment_doesNotCallOrderStateMutator(): void + { + $otherPayment = $this->createMock(PaymentInterface::class); + $otherPayment->method('getId')->willReturn(999); + + $order = $this->createMock(OrderInterface::class); + $order->method('getId')->willReturn(42); + $order->method('getLastPayment')->willReturn($otherPayment); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getState')->willReturn(PaymentInterface::STATE_NEW); + $payment->method('getDetails')->willReturn(['status' => PayPlugApiClientInterface::STATUS_CAPTURED]); + $payment->method('getOrder')->willReturn($order); + $payment->method('getId')->willReturn(1); + $method = $this->createMock(PaymentMethodInterface::class); + $payment->method('getMethod')->willReturn($method); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '{}']]); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $client = $this->createMock(PayPlugApiClientInterface::class); + $resource = $this->createMock(PayplugResourcePayment::class); + $client->method('treat')->willReturn($resource); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client); + + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->__invoke(new NotifyPaymentRequest('hash')); + } + + private function prepareNormalFlow(string $status, int $orderId): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getState')->willReturn(PaymentInterface::STATE_NEW); + $payment->method('getDetails')->willReturn(['status' => $status]); + $payment->method('getId')->willReturn(1); + $method = $this->createMock(PaymentMethodInterface::class); + $payment->method('getMethod')->willReturn($method); + + $order = $this->createMock(OrderInterface::class); + $order->method('getId')->willReturn($orderId); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getPayload')->willReturn(['http_request' => ['content' => '{}']]); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $client = $this->createMock(PayPlugApiClientInterface::class); + $resource = $this->createMock(PayplugResourcePayment::class); + $client->method('treat')->willReturn($resource); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($client); + } +} diff --git a/tests/PHPUnit/ConfigurationRepository/PayplugConfigurationRepositoryTest.php b/tests/PHPUnit/ConfigurationRepository/PayplugConfigurationRepositoryTest.php new file mode 100644 index 00000000..34c20686 --- /dev/null +++ b/tests/PHPUnit/ConfigurationRepository/PayplugConfigurationRepositoryTest.php @@ -0,0 +1,157 @@ +gatewayConfig = $this->createMock(GatewayConfigInterface::class); + } + + public function testGet_liveMode_readsFromLiveClientScope(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => ['client_id' => 'live-id'], + 'test_client' => ['client_id' => 'test-id'], + ]); + + self::assertSame('live-id', $this->repository()->get('client_id')); + } + + public function testGet_testMode_readsFromTestClientScope(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => false, + 'live_client' => ['client_id' => 'live-id'], + 'test_client' => ['client_id' => 'test-id'], + ]); + + self::assertSame('test-id', $this->repository()->get('client_id')); + } + + public function testGet_missingKey_returnsNull(): void + { + $this->gatewayConfig->method('getConfig')->willReturn(['live' => true, 'live_client' => []]); + + self::assertNull($this->repository()->get('client_id')); + } + + public function testSet_writesIntoActiveScopeAndPersistsWholeConfig(): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => ['client_id' => 'old-id'], + 'test_client' => ['client_id' => 'test-id'], + ]); + + $this->gatewayConfig->expects(self::once())->method('setConfig')->with([ + 'live' => true, + 'live_client' => ['client_id' => 'new-id'], + 'test_client' => ['client_id' => 'test-id'], + ]); + + $this->repository()->set('client_id', 'new-id'); + } + + /** + * @dataProvider provideRequiredCredentialGetters + */ + public function testRequiredCredentialGetters_present_returnValue(string $method, string $configKey): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => [$configKey => 'the-value'], + ]); + + self::assertSame('the-value', $this->repository()->{$method}()); + } + + /** + * @dataProvider provideRequiredCredentialGetters + * + * The exception message must name the missing key and the factory, never a credential + * value — there is nothing sensitive to redact here since the value is simply absent, but + * this locks the message shape so a future edit can't start interpolating $value instead. + */ + public function testRequiredCredentialGetters_missing_throwsApiExceptionWithoutLeakingSecrets( + string $method, + string $configKey, + ): void + { + $this->gatewayConfig->method('getConfig')->willReturn(['live' => true, 'live_client' => []]); + $this->gatewayConfig->method('getFactoryName')->willReturn('payplug'); + + try { + $this->repository()->{$method}(); + self::fail('Expected ApiException to be thrown.'); + } catch (ApiException $exception) { + self::assertSame(\sprintf('Missing "%s" in gateway configuration "payplug".', $configKey), $exception->getMessage()); + } + } + + /** + * @return array + */ + public static function provideRequiredCredentialGetters(): array + { + return [ + 'clientId' => ['getClientId', 'client_id'], + 'clientSecret' => ['getClientSecret', 'client_secret'], + ]; + } + + /** + * @dataProvider provideOptionalCredentialGetters + */ + public function testOptionalCredentialGetters_present_returnValue(string $method, string $configKey): void + { + $this->gatewayConfig->method('getConfig')->willReturn([ + 'live' => true, + 'live_client' => [$configKey => 'the-value'], + ]); + + self::assertSame('the-value', $this->repository()->{$method}()); + } + + /** + * getPublicKeyId()/getPublicKeyValue() have no production writer yet (Hosted Fields isn't + * built) — unlike client_id/client_secret, a missing value must not throw, or the contract + * would be unimplementable until a future ticket adds that writer. + * + * @dataProvider provideOptionalCredentialGetters + */ + public function testOptionalCredentialGetters_missing_returnEmptyString(string $method): void + { + $this->gatewayConfig->method('getConfig')->willReturn(['live' => true, 'live_client' => []]); + + self::assertSame('', $this->repository()->{$method}()); + } + + /** + * @return array + */ + public static function provideOptionalCredentialGetters(): array + { + return [ + 'publicKeyId' => ['getPublicKeyId', 'public_key_id'], + 'publicKeyValue' => ['getPublicKeyValue', 'public_key_value'], + ]; + } + + private function repository(): PayplugConfigurationRepository + { + return new PayplugConfigurationRepository($this->gatewayConfig); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/PayplugOrderStateMutatorTest.php b/tests/PHPUnit/PaymentProcessing/PayplugOrderStateMutatorTest.php new file mode 100644 index 00000000..d4409908 --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/PayplugOrderStateMutatorTest.php @@ -0,0 +1,127 @@ +orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->mutator = new PayplugOrderStateMutator($this->orderRepository, $this->stateMachine, $this->entityManager); + } + + /** + * THREE_DS_PENDING must stay a no-op — the order remains `new` until the async webhook + * resolves it. Neither the order repository nor the state machine should even be touched. + */ + public function testApply_threeDsPending_isNoOp(): void + { + $this->orderRepository->expects(self::never())->method('findOrderById'); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->mutator->apply('order-1', PaymentOutcome::THREE_DS_PENDING); + } + + /** + * @dataProvider provideMappedOutcomes + */ + public function testApply_mappedOutcome_appliesExpectedTransition(string $outcome, string $expectedTransition): void + { + $order = $this->createMock(OrderInterface::class); + $payment = $this->createMock(PaymentInterface::class); + $order->method('getLastPayment')->willReturn($payment); + + $this->orderRepository->method('findOrderById')->with('order-1')->willReturn($order); + $this->stateMachine->method('can')->with($payment, PaymentTransitions::GRAPH, $expectedTransition)->willReturn(true); + $this->stateMachine->expects(self::once())->method('apply')->with($payment, PaymentTransitions::GRAPH, $expectedTransition); + $this->entityManager->expects(self::once())->method('flush'); + + $this->mutator->apply('order-1', $outcome); + } + + /** + * @return array + */ + public static function provideMappedOutcomes(): array + { + return [ + 'paid' => [PaymentOutcome::PAID, PaymentTransitions::TRANSITION_COMPLETE], + 'authorized' => [PaymentOutcome::AUTHORIZED, PaymentTransitions::TRANSITION_AUTHORIZE], + 'capture_required' => [PaymentOutcome::CAPTURE_REQUIRED, PaymentTransitions::TRANSITION_PROCESS], + 'refunded' => [PaymentOutcome::REFUNDED, PaymentTransitions::TRANSITION_REFUND], + 'failed' => [PaymentOutcome::FAILED, PaymentTransitions::TRANSITION_FAIL], + ]; + } + + /** + * A retried webhook landing on a payment already past this transition is a silent no-op, + * not a thrown workflow exception — mirrors the plugin's existing PaymentStateResolver. + */ + public function testApply_transitionNotAvailable_isSilentNoOp(): void + { + $order = $this->createMock(OrderInterface::class); + $payment = $this->createMock(PaymentInterface::class); + $order->method('getLastPayment')->willReturn($payment); + + $this->orderRepository->method('findOrderById')->willReturn($order); + $this->stateMachine->method('can')->willReturn(false); + $this->stateMachine->expects(self::never())->method('apply'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->mutator->apply('order-1', PaymentOutcome::PAID); + } + + public function testApply_orderNotFound_throwsPaymentNotFoundException(): void + { + $this->orderRepository->method('findOrderById')->with('missing-order')->willReturn(null); + + $this->expectException(PaymentNotFoundException::class); + + $this->mutator->apply('missing-order', PaymentOutcome::PAID); + } + + public function testApply_orderHasNoPayment_throwsPaymentNotFoundException(): void + { + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn(null); + $this->orderRepository->method('findOrderById')->willReturn($order); + + $this->expectException(PaymentNotFoundException::class); + + $this->mutator->apply('order-1', PaymentOutcome::PAID); + } + + public function testApply_unmappedOutcome_throwsInvalidArgumentException(): void + { + $this->orderRepository->expects(self::never())->method('findOrderById'); + + $this->expectException(\InvalidArgumentException::class); + + $this->mutator->apply('order-1', 'not_a_real_outcome'); + } +} diff --git a/tests/PHPUnit/Spike/SpikeIntegrationTest.php b/tests/PHPUnit/Spike/SpikeIntegrationTest.php new file mode 100644 index 00000000..c1f82d8d --- /dev/null +++ b/tests/PHPUnit/Spike/SpikeIntegrationTest.php @@ -0,0 +1,177 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testOrderStateMutator_realStateMachine_transitionsPaymentToCompleted(): void + { + $order = $this->createOrderWithFreshPayment(); + $payment = $order->getLastPayment(); + self::assertNotNull($payment); + self::assertSame(PaymentInterface::STATE_NEW, $payment->getState()); + + $mutator = new PayplugOrderStateMutator( + self::getContainer()->get('sylius.repository.order'), + self::getContainer()->get(StateMachineInterface::class), + $this->entityManager, + ); + + $mutator->apply((string) $order->getId(), PaymentOutcome::PAID); + + $this->entityManager->refresh($payment); + self::assertSame(PaymentInterface::STATE_COMPLETED, $payment->getState()); + } + + public function testOrderStateMutator_threeDsPending_leavesRealPaymentUntouched(): void + { + $order = $this->createOrderWithFreshPayment(); + $payment = $order->getLastPayment(); + self::assertNotNull($payment); + + $mutator = new PayplugOrderStateMutator( + self::getContainer()->get('sylius.repository.order'), + self::getContainer()->get(StateMachineInterface::class), + $this->entityManager, + ); + + $mutator->apply((string) $order->getId(), PaymentOutcome::THREE_DS_PENDING); + + $this->entityManager->refresh($payment); + self::assertSame(PaymentInterface::STATE_NEW, $payment->getState()); + } + + public function testTokenCache_realCachePool_roundTripsThroughRealAdapter(): void + { + $cachePool = self::getContainer()->get(CacheItemPoolInterface::class); + $tokenCache = new PayplugTokenCache($cachePool); + + $tokenCache->set('pre3469-spike-token', 'jwt-value', 300); + self::assertSame('jwt-value', $tokenCache->get('pre3469-spike-token')); + + $tokenCache->delete('pre3469-spike-token'); + self::assertNull($tokenCache->get('pre3469-spike-token')); + } + + public function testConfigurationRepository_realGatewayConfig_survivesADoctrineRoundTrip(): void + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName('payplug_pre3469_spike'); + $gatewayConfig->setGatewayName('PRE-3469 spike'); + $gatewayConfig->setConfig(['live' => false, 'test_client' => ['client_id' => 'spike-client-id']]); + $this->entityManager->persist($gatewayConfig); + $this->entityManager->flush(); + $id = $gatewayConfig->getId(); + $this->entityManager->clear(); + + /** @var GatewayConfig $reloaded */ + $reloaded = $this->entityManager->find(GatewayConfig::class, $id); + (new PayplugConfigurationRepository($reloaded))->set('client_secret', 'spike-secret-value'); + $this->entityManager->flush(); + $this->entityManager->clear(); + + /** @var GatewayConfig $reloadedAgain */ + $reloadedAgain = $this->entityManager->find(GatewayConfig::class, $id); + $repository = new PayplugConfigurationRepository($reloadedAgain); + + self::assertSame('spike-client-id', $repository->getClientId()); + self::assertSame('spike-secret-value', $repository->getClientSecret()); + } + + public function testPaymentRepository_realDoctrinePersistence_roundTripsAndTracksIdempotency(): void + { + $repository = new SyliusPaymentRepository($this->entityManager); + $operationId = 'spike-op-' . \bin2hex(\random_bytes(6)); + $orderId = 'spike-order-' . \bin2hex(\random_bytes(6)); + $operationData = new OperationData($operationId, '4001', PaymentOutcome::PAID, 1500, $orderId); + + $repository->save($operationData); + $this->entityManager->clear(); + + self::assertFalse($repository->isTreated($operationId)); + + $fetched = $repository->getByOperationId($operationId); + self::assertSame($orderId, $fetched->orderId); + self::assertSame(1500, $fetched->amount); + + $repository->markTreated($operationId); + $this->entityManager->clear(); + + self::assertTrue($repository->isTreated($operationId)); + } + + /** + * Self-contained on purpose: earlier drafts hunted for a fixture payment already sitting in + * state `new`, but repeated test runs against the same disposable database exhaust that + * finite pool (fixtures don't reload between runs) — a real payment always transitions + * *away* from `new`, so the pool only shrinks. Attaching a fresh Payment to an existing + * fixture Order sidesteps that without needing a full from-scratch Order (channel, customer, + * currency, locale, number, token — all fixture-provided here for free). + */ + private function createOrderWithFreshPayment(): OrderInterface + { + $order = $this->entityManager->getRepository(Order::class)->createQueryBuilder('o') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + self::assertNotNull($order, 'Fixtures must include at least one order.'); + + $method = $order->getLastPayment()?->getMethod() + ?? $this->entityManager->getRepository(PaymentMethod::class)->createQueryBuilder('m')->setMaxResults(1)->getQuery()->getOneOrNullResult() + ; + self::assertNotNull($method, 'Fixtures must include at least one payment method.'); + + $payment = new Payment(); + $payment->setState(PaymentInterface::STATE_NEW); + $payment->setCurrencyCode($order->getCurrencyCode() ?? 'EUR'); + $payment->setAmount($order->getTotal()); + $payment->setMethod($method); + $order->addPayment($payment); + + $this->entityManager->persist($payment); + $this->entityManager->flush(); + + return $order; + } +} diff --git a/tests/PHPUnit/Spike/SyliusPaymentRepositoryTest.php b/tests/PHPUnit/Spike/SyliusPaymentRepositoryTest.php new file mode 100644 index 00000000..a1ff7cd9 --- /dev/null +++ b/tests/PHPUnit/Spike/SyliusPaymentRepositoryTest.php @@ -0,0 +1,152 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->objectRepository = $this->createMock(EntityRepository::class); + $this->entityManager->method('getRepository')->with(PayplugOperation::class)->willReturn($this->objectRepository); + + $this->repository = new SyliusPaymentRepository($this->entityManager); + } + + public function testGetByOrderId_found_returnsMatchingOperationData(): void + { + $entity = PayplugOperation::fromOperationData($this->operationData()); + $this->objectRepository->method('findOneBy')->with(['orderId' => 'order-1'])->willReturn($entity); + + $result = $this->repository->getByOrderId('order-1'); + + self::assertSame('op-1', $result->operationId); + self::assertSame('order-1', $result->orderId); + } + + public function testGetByOrderId_notFound_throwsPaymentNotFoundException(): void + { + $this->objectRepository->method('findOneBy')->willReturn(null); + + $this->expectException(PaymentNotFoundException::class); + + $this->repository->getByOrderId('missing-order'); + } + + public function testGetByOperationId_found_returnsMatchingOperationData(): void + { + $entity = PayplugOperation::fromOperationData($this->operationData()); + $this->objectRepository->method('findOneBy')->with(['operationId' => 'op-1'])->willReturn($entity); + + $result = $this->repository->getByOperationId('op-1'); + + self::assertSame('order-1', $result->orderId); + } + + public function testGetByOperationId_notFound_throwsPaymentNotFoundException(): void + { + $this->objectRepository->method('findOneBy')->willReturn(null); + + $this->expectException(PaymentNotFoundException::class); + + $this->repository->getByOperationId('missing-op'); + } + + public function testSave_newOperation_persistsAndFlushes(): void + { + $this->objectRepository->method('findOneBy')->with(['operationId' => 'op-1'])->willReturn(null); + + $this->entityManager->expects(self::once())->method('persist') + ->with(self::callback(fn (PayplugOperation $entity): bool => 'op-1' === $entity->getOperationId() && 'order-1' === $entity->getOrderId())); + $this->entityManager->expects(self::once())->method('flush'); + + $this->repository->save($this->operationData()); + } + + public function testSave_existingOperation_updatesInPlaceWithoutPersisting(): void + { + $existing = PayplugOperation::fromOperationData($this->operationData()); + $this->objectRepository->method('findOneBy')->willReturn($existing); + + $this->entityManager->expects(self::never())->method('persist'); + $this->entityManager->expects(self::once())->method('flush'); + + $updated = new OperationData('op-1', '4001', PaymentOutcome::REFUNDED, 500, 'order-1'); + $this->repository->save($updated); + + self::assertSame(PaymentOutcome::REFUNDED, $existing->toOperationData()->outcome); + self::assertSame(500, $existing->toOperationData()->amount); + } + + public function testMarkTreated_existing_marksAndFlushes(): void + { + $entity = PayplugOperation::fromOperationData($this->operationData()); + $this->objectRepository->method('findOneBy')->with(['operationId' => 'op-1'])->willReturn($entity); + $this->entityManager->expects(self::once())->method('flush'); + + $this->repository->markTreated('op-1'); + + self::assertTrue($entity->isTreated()); + } + + public function testMarkTreated_missing_throwsPaymentNotFoundException(): void + { + $this->objectRepository->method('findOneBy')->willReturn(null); + + $this->expectException(PaymentNotFoundException::class); + + $this->repository->markTreated('missing-op'); + } + + public function testIsTreated_untreatedOperation_returnsFalse(): void + { + $entity = PayplugOperation::fromOperationData($this->operationData()); + $this->objectRepository->method('findOneBy')->willReturn($entity); + + self::assertFalse($this->repository->isTreated('op-1')); + } + + public function testIsTreated_treatedOperation_returnsTrue(): void + { + $entity = PayplugOperation::fromOperationData($this->operationData()); + $entity->markTreated(); + $this->objectRepository->method('findOneBy')->willReturn($entity); + + self::assertTrue($this->repository->isTreated('op-1')); + } + + /** + * Idempotency checks must be tolerant of "never saved" rather than throwing — a webhook + * asking "have I already treated this?" before any operation was ever persisted is a + * legitimate call, not an error. + */ + public function testIsTreated_unknownOperation_returnsFalse(): void + { + $this->objectRepository->method('findOneBy')->willReturn(null); + + self::assertFalse($this->repository->isTreated('unknown-op')); + } + + private function operationData(): OperationData + { + return new OperationData('op-1', '4001', PaymentOutcome::PAID, 1000, 'order-1'); + } +} diff --git a/tests/PHPUnit/TokenCache/PayplugTokenCacheTest.php b/tests/PHPUnit/TokenCache/PayplugTokenCacheTest.php new file mode 100644 index 00000000..866e5fdc --- /dev/null +++ b/tests/PHPUnit/TokenCache/PayplugTokenCacheTest.php @@ -0,0 +1,75 @@ +cache = $this->createMock(CacheItemPoolInterface::class); + $this->tokenCache = new PayplugTokenCache($this->cache); + } + + public function testGet_hit_returnsStoredValue(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn('jwt-value'); + $this->cache->method('getItem')->with('token-key')->willReturn($item); + + self::assertSame('jwt-value', $this->tokenCache->get('token-key')); + } + + public function testGet_miss_returnsNull(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $this->cache->method('getItem')->willReturn($item); + + self::assertNull($this->tokenCache->get('token-key')); + } + + /** + * PSR-6's CacheItemInterface::get() is typed mixed — defend against a non-string value + * ever having been stored under this key (e.g. by another caller of the same pool). + */ + public function testGet_hitWithNonStringValue_returnsNull(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn(42); + $this->cache->method('getItem')->willReturn($item); + + self::assertNull($this->tokenCache->get('token-key')); + } + + public function testSet_storesValueWithTtlAndSaves(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('set')->with('jwt-value'); + $item->expects(self::once())->method('expiresAfter')->with(298); + $this->cache->method('getItem')->with('token-key')->willReturn($item); + $this->cache->expects(self::once())->method('save')->with($item); + + $this->tokenCache->set('token-key', 'jwt-value', 298); + } + + public function testDelete_removesItem(): void + { + $this->cache->expects(self::once())->method('deleteItem')->with('token-key'); + + $this->tokenCache->delete('token-key'); + } +}