Retour au blog
Tests

PrestaFlow : tester un tunnel de paiement Stripe

PrestaEdit •
PrestaFlow : tester un tunnel de paiement Stripe

Décor

Un tunnel de paiement Stripe casse toutes les habitudes des scénarios PrestaFlow standard :

  • Le formulaire de carte n’est pas dans le DOM de la boutique — c’est une <iframe> servie par js.stripe.com, cross-origin, immune aux setValue classiques.
  • Le paiement passe potentiellement par un 3DS — nouvelle iframe (Elements) ou redirection (Checkout) vers un challenge géré par la banque du client. La suite doit savoir naviguer là-dedans et revenir.
  • La confirmation est en deux temps — Stripe redirige la boutique vers une URL de retour (/order-confirmation?id_order=…), puis notifie de façon asynchrone, par webhook (payment_intent.succeeded), que le paiement est bien encaissé. Le premier est visible par Chrome, le second non.

Cette annexe couvre les deux intégrations Stripe qu’on rencontre sur PrestaShop :

  • Stripe Elements (inline) — le module colle une iframe de carte dans le tunnel PS ; le client ne quitte pas la boutique.
  • Stripe Checkout (hosted) — le module redirige vers checkout.stripe.com où Stripe prend en charge le formulaire ; retour ensuite sur PS.

Le mode test et les cartes magiques

Stripe fournit un mode test activable par une clé API sk_test_… / pk_test_… — configurez le module PS Stripe avec ces clés dans un .env de test ou un init-script Flashlight, jamais en prod.

Quatre numéros de carte à connaître (en mode test uniquement, avec n’importe quelle date d’expiration future, par exemple 12/34, et n’importe quel CVC à trois chiffres, par exemple 123) :

NuméroComportement
4242 4242 4242 4242Paiement accepté, pas de 3DS
4000 0025 0000 3155Paiement accepté après authentification 3DS (challenge)
4000 0000 0000 0002Paiement refusé par la banque (« Your card was declined »)
4000 0000 0000 9995Fonds insuffisants

On teste chaque comportement séparément — pas de « scénario global » qui empile les 4.

Écrire une Page pour Stripe Elements

Le formulaire de carte étant dans une iframe cross-origin, on ne peut pas y setValue : le document.querySelector() de la boutique ne voit pas son contenu. Deux outils réels de chrome-php (la version 1.16 livrée avec PrestaFlow 1.7.1) permettent de le contourner :

  • la souris et le clavier : mouse()->move()->click() puis keyboard()->typeText() passent par Input.dispatch*Event du protocole Chrome DevTools (CDP). Chrome route ces événements vers l’élément qui a le focus, iframe cross-origin comprise. C’est ce qu’on utilise pour remplir la carte ;
  • le CDP brut via getSession()->sendMessageSync(new Message(...)) : on retrouve la frame Stripe (cible iframe dans Target.getTargets, ou nœud de Page.getFrameTree) et on y exécute du JS avec Runtime.evaluate. C’est ce qu’on utilise pour cliquer dans le challenge 3DS.

Créez tests/prestaflow/Pages/v9/Support/StripeElements/Page.php. importPage() cherche la Page dans le dossier de la version majeure testée (v9 pour PrestaShop 9) ; si vous testez aussi PS 8, dupliquez-la dans v8/ en adaptant le namespace et la classe parente.

<?php

namespace Tests\Pages\v9\Support\StripeElements;

use HeadlessChromium\Communication\Message;
use PrestaFlow\Library\Pages\v9\FrontOffice\Page as BasePage;

class Page extends BasePage
{
    public function defineSelectors()
    {
        return [
            // iframe du champ carte (Card Element ou Payment Element) : à vérifier dans les DevTools
            'cardIframe' => 'iframe[src*="elements-inner-card"], iframe[src*="elements-inner-payment"]',
        ];
    }

    public function fillCard(string $number, string $expiry = '1234', string $cvc = '123'): void
    {
        $iframe = $this->getSelector('cardIframe');
        $this->waitVisible($iframe, 15000);

        // Position du champ numéro dans le viewport : bord gauche de l'iframe, à mi-hauteur
        $rect = $this->getPage()->evaluate(sprintf(
            '(function(){var f=document.querySelector(%s);f.scrollIntoView({block:"center"});'
            . 'var r=f.getBoundingClientRect();return {x:r.left+30,y:r.top+r.height/2};})()',
            json_encode($iframe)
        ))->getReturnValue();

        // Un vrai clic donne le focus au champ numéro, dans l'iframe
        $this->getPage()->mouse()->move($rect['x'], $rect['y'])->click();

        // La frappe va à l'élément focus ; Stripe passe seul au champ suivant
        // quand un champ est complet (numéro → expiration → CVC)
        $this->getPage()->keyboard()->typeText($number . $expiry . $cvc);
    }

    public function confirm3DS(int $timeoutMs = 15000): void
    {
        // Page de test Stripe : bouton « Complete » (sélecteur à vérifier)
        $js = '(function(){var b=document.querySelector("#test-source-authorize-3ds");'
            . 'if(!b)return false;b.click();return true;})()';

        $this->waitUntil(
            fn () => $this->evaluateInFrames('hooks.stripe.com', $js) === true,
            $timeoutMs,
            'Bouton de validation 3DS introuvable'
        );
    }

    /**
     * Exécute $js dans chaque frame dont l'URL contient $urlPart.
     * Renvoie true dès qu'une exécution renvoie true.
     */
    private function evaluateInFrames(string $urlPart, string $js): bool
    {
        $session = $this->getPage()->getSession();
        $connection = $session->getConnection();

        // 1. Iframe hors processus (isolation des sites, cas par défaut) :
        //    c'est une cible CDP à part, à laquelle on s'attache.
        $targets = $connection->sendMessageSync(new Message('Target.getTargets'))
            ->getResultData('targetInfos') ?? [];
        foreach ($targets as $target) {
            if ($target['type'] === 'iframe' && str_contains($target['url'], $urlPart)) {
                $frameSession = $connection->createSession($target['targetId']);
                $result = $frameSession->sendMessageSync(new Message('Runtime.evaluate', [
                    'expression' => $js,
                    'returnByValue' => true,
                ]))->getResultData('result');
                if (($result['value'] ?? null) === true) {
                    return true;
                }
            }
        }

        // 2. Iframe dans le processus de la page : on la trouve dans l'arbre
        //    des frames et on crée un contexte d'exécution dedans.
        $tree = $session->sendMessageSync(new Message('Page.getFrameTree'))->getResultData('frameTree');
        foreach ($this->findFrameIds($tree ?? [], $urlPart) as $frameId) {
            $contextId = $session->sendMessageSync(new Message('Page.createIsolatedWorld', [
                'frameId' => $frameId,
                'worldName' => 'prestaflow',
            ]))->getResultData('executionContextId');
            $result = $session->sendMessageSync(new Message('Runtime.evaluate', [
                'expression' => $js,
                'contextId' => $contextId,
                'returnByValue' => true,
            ]))->getResultData('result');
            if (($result['value'] ?? null) === true) {
                return true;
            }
        }

        return false;
    }

    private function findFrameIds(array $node, string $urlPart): array
    {
        $ids = [];
        if (str_contains($node['frame']['url'] ?? '', $urlPart)) {
            $ids[] = $node['frame']['id'];
        }
        foreach ($node['childFrames'] ?? [] as $child) {
            $ids = [...$ids, ...$this->findFrameIds($child, $urlPart)];
        }

        return $ids;
    }
}

Scénario 1 : paiement accepté (carte 4242)

Objectif : passer une commande de bout en bout avec Stripe Elements et la carte magique standard.

<?php

namespace Tests\Suites;

use PrestaFlow\Library\Expects\Expect;
use PrestaFlow\Library\Tests\TestsSuite;

class StripeCheckoutAccepted extends TestsSuite
{
    public function init()
    {
        $this->importPage('FrontOffice\Login');
        $this->importPage('FrontOffice\Product');
        $this->importPage('FrontOffice\Cart');
        $this->importPage('FrontOffice\Checkout');
        $this->importPage('FrontOffice\OrderConfirmation');
        $this->importPage('Support\StripeElements', domain: 'Tests');

        extract($this->pages);

        $this
        ->describe('Stripe Elements — paiement accepté carte 4242')
        ->it('log client', function () use ($frontOfficeLoginPage) {
            $frontOfficeLoginPage->goToPage('login');
            $frontOfficeLoginPage->login();
        })
        ->it('ajoute un produit et va au checkout', function () use ($frontOfficeProductPage, $frontOfficeCartPage, $frontOfficeCheckoutPage) {
            $frontOfficeProductPage->goToProductPath('1-1-hummingbird-printed-t-shirt.html');
            $frontOfficeProductPage->addToCart(1);
            $frontOfficeCartPage->goToCart();
            $frontOfficeCartPage->proceedToCheckout();
            $frontOfficeCheckoutPage->confirmAddresses();
            $frontOfficeCheckoutPage->chooseShipping();
        })
        ->it('choisit Stripe et remplit la carte', function () use ($frontOfficeCheckoutPage, $supportStripeElementsPage) {
            $frontOfficeCheckoutPage->click('input[name="payment-option"][data-module-name="stripe_official"]');
            $supportStripeElementsPage->fillCard('4242424242424242');
            $frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('termsCheckbox'));
            $frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('placeOrderButton'));
        })
        ->it('atterrit sur la confirmation de commande', function () use ($frontOfficeOrderConfirmationPage) {
            // Stripe.js confirme le paiement puis redirige : on attend le bloc de confirmation.
            $frontOfficeOrderConfirmationPage->waitVisible(
                $frontOfficeOrderConfirmationPage->getSelector('confirmationBlock'),
                20000
            );
            Expect::that($frontOfficeOrderConfirmationPage->isConfirmed())->isTheSameAs(true);
            Expect::that($frontOfficeOrderConfirmationPage->getOrderReference())->isNotEmpty();

            // Réutilisée plus bas pour contrôler la commande en BO.
            $this->store('orderReference', $frontOfficeOrderConfirmationPage->getOrderReference());
        });
    }
}

Scénario 2 : refus banque (carte 0002)

Même préambule, on change juste la carte et on vérifie le message d’erreur qui reste sur la page checkout. Le conteneur du message dépend du module, et son texte suit la langue du client : « Your card was declined. » en anglais.

->it('choisit Stripe et remplit une carte refusée', function () use ($frontOfficeCheckoutPage, $supportStripeElementsPage) {
    $frontOfficeCheckoutPage->click('input[name="payment-option"][data-module-name="stripe_official"]');
    $supportStripeElementsPage->fillCard('4000000000000002');
    $frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('termsCheckbox'));
    $frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('placeOrderButton'));
})
->it('reste sur checkout avec un message d\'erreur', function () use ($frontOfficeCheckoutPage) {
    // Pas de rechargement : l'erreur Stripe s'affiche sans navigation.
    // Sélecteur du message d'erreur : à adapter au module.
    $frontOfficeCheckoutPage->waitVisible('.stripe-error', 15000);
    Expect::that($frontOfficeCheckoutPage->getTextContent('.stripe-error'))
        ->contains('declined');
});

Scénario 3 : challenge 3DS (carte 3155)

Le placement de commande démarre, Stripe ouvre l’iframe de challenge, le scénario clique le bouton de validation de la page de test à l’intérieur, la commande passe.

->it('choisit Stripe et remplit une carte 3DS', function () use ($frontOfficeCheckoutPage, $supportStripeElementsPage) {
    $frontOfficeCheckoutPage->click('input[name="payment-option"][data-module-name="stripe_official"]');
    $supportStripeElementsPage->fillCard('4000002500003155');
    $frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('termsCheckbox'));
    $frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('placeOrderButton'));
})
->it('complète le challenge 3DS', function () use ($supportStripeElementsPage) {
    $supportStripeElementsPage->confirm3DS();
})
->it('atterrit sur la confirmation', function () use ($frontOfficeOrderConfirmationPage) {
    // Bump du timeout — le 3DS ajoute quelques secondes.
    $frontOfficeOrderConfirmationPage->waitVisible(
        $frontOfficeOrderConfirmationPage->getSelector('confirmationBlock'),
        20000
    );
    Expect::that($frontOfficeOrderConfirmationPage->isConfirmed())->isTheSameAs(true);
});

Stripe Checkout (hosted) — la variante redirection

Si le module utilise Stripe Checkout (redirection vers checkout.stripe.com) plutôt qu’Elements, la Page ci-dessus ne sert pas — on est sur un domaine tiers, PrestaFlow y navigue comme sur n’importe quelle page. Le formulaire y est standard (pas d’iframe imbriquée), les mêmes cartes magiques fonctionnent.

->it('atterrit sur la page hébergée par Stripe', function () use ($frontOfficeCheckoutPage) {
    $frontOfficeCheckoutPage->waitUntil(
        fn () => str_contains($frontOfficeCheckoutPage->getPage()->getCurrentUrl(), 'checkout.stripe.com'),
        15000,
        'Pas de redirection vers checkout.stripe.com'
    );
})
->it('remplit la carte sur Stripe et confirme', function () use ($frontOfficeCheckoutPage) {
    $frontOfficeCheckoutPage->setValue('input#email', 'test@example.com');
    $frontOfficeCheckoutPage->setValue('input#cardNumber', '4242424242424242');
    $frontOfficeCheckoutPage->setValue('input#cardExpiry', '1234');
    $frontOfficeCheckoutPage->setValue('input#cardCvc', '123');
    $frontOfficeCheckoutPage->setValue('input#billingName', 'Jean Test');
    $frontOfficeCheckoutPage->click('button[data-testid="hosted-payment-submit-button"]');
})
->it('revient sur PrestaShop en confirmation', function () use ($frontOfficeOrderConfirmationPage) {
    $frontOfficeOrderConfirmationPage->waitVisible(
        $frontOfficeOrderConfirmationPage->getSelector('confirmationBlock'),
        20000
    );
    Expect::that($frontOfficeOrderConfirmationPage->isConfirmed())->isTheSameAs(true);
});

Vérifier le webhook payment_intent.succeeded

Le paiement en base côté Stripe et l’ordre côté PrestaShop passent en « paid » via le webhook payment_intent.succeeded — appel serveur-à-serveur, invisible depuis Chrome. Deux options :

  • Attendre passivement — en mode test, Stripe envoie bien de vrais webhooks vers l’endpoint enregistré pour ce mode, à condition que Stripe puisse le joindre depuis Internet. Le scénario relit le statut de la commande en BO jusqu’à ce qu’il change, avec un délai maximal.
  • Simuler activement — POSTer soi-même un payload payment_intent.succeeded vers le contrôleur du module et vérifier l’effet. C’est le pattern couvert par l’annexe tester les webhooks — même code, appliqué à Stripe.

À ajouter à la fin de la suite StripeCheckoutAccepted, qui a stocké orderReference à la confirmation, après avoir importé BackOffice\Login, BackOffice\Orders et BackOffice\OrderView :

->it('la commande passe en « Paiement accepté » après le webhook', function () use ($backOfficeLoginPage, $backOfficeOrdersPage, $backOfficeOrderViewPage) {
    $backOfficeLoginPage->goToPage('index');
    $backOfficeLoginPage->login();

    // Le webhook arrive de façon asynchrone : on rouvre la commande jusqu'à ce que le statut change.
    // Le libellé du statut suit la langue de l'employé BO.
    $backOfficeOrdersPage->waitUntil(function () use ($backOfficeOrdersPage, $backOfficeOrderViewPage) {
        $backOfficeOrdersPage->goTo();
        $backOfficeOrdersPage->filterByReference($this->retrieve('orderReference'));
        $backOfficeOrdersPage->openOrder(1);

        return str_contains($backOfficeOrderViewPage->getCurrentStatus(), 'Paiement accepté');
    }, 30000, 'Statut toujours différent de « Paiement accepté » : webhook non reçu ?');

    Expect::that($backOfficeOrderViewPage->getCurrentStatus())->contains('Paiement accepté');
});

Interaction avec les autres annexes

  • Tester les webhooks — la moitié serveur-à-serveur du paiement Stripe (le payment_intent.succeeded que Chrome ne voit pas).
  • Mocker les API tierces avec WireMock — pour éviter la dépendance à Stripe test mode (air-gap, coupures API), remplacer Stripe par un mock complet côté serveur.
  • Fixtures et seed data — poser les clés sk_test_… / pk_test_… dans la configuration du module au boot Flashlight, pour que la boutique de test soit toujours en mode test Stripe.
  • Popups intempestifs — Stripe.js peut afficher une bannière consent CMP la première fois. Le pattern d’auto-dismiss s’y applique.

Notes

Dans la Série PrestaFlow — article 35 sur 36