PrestaFlow : tester un module qui reçoit des webhooks
Décor
Beaucoup de modules PS parlent à des tiers via HTTP hors du flux navigateur : Stripe pousse un payment_intent.succeeded, Chronopost notifie une livraison, Mirakl envoie une commande marketplace, un ERP interne reçoit chaque nouvelle facture. Ces échanges sont serveur-à-serveur. PrestaFlow pilote Chrome, Chrome ne voit rien de ce qui se passe entre votre boutique et un serveur externe.
Pour tester ces flux, il faut jouer le rôle du tiers — soit en simulant l’appel qu’il ferait vers votre boutique, soit en capturant l’appel que votre boutique lui envoie. Pour la capture, un composant tourne à côté de PrestaFlow et de la boutique : c’est un side-car, comme le conteneur Mailpit vu dans l’annexe tester les emails envoyés.
Un point commun aux deux cas : ces appels HTTP partent du processus PHP de PrestaFlow, pas de Chrome. Un vrai tiers n’a ni cookies de session, ni contraintes CORS, ni onglet ouvert sur la boutique ; un fetch() lancé dans la page ne reproduirait pas ses conditions. Une Page utilitaire fait donc ses appels en curl, et le navigateur ne sert qu’à vérifier l’effet dans le BO ou le front.
Deux cas à distinguer
Avant d’écrire quoi que ce soit, il faut trancher : dans quel sens va le webhook ?
Cas A — la boutique reçoit un webhook. Stripe notifie un paiement encaissé, un transporteur notifie un tracking, une marketplace pousse une commande. Le scénario envoie lui-même le POST vers le contrôleur du module PS, avec le corps et les en-têtes qu’enverrait le tiers. On vérifie l’effet côté boutique : statut de commande modifié, ligne dans une table de log, email envoyé, stock décrémenté.
Cas B — la boutique envoie un webhook. Le module notifie un ERP, poste un message Slack maison, ping l’endpoint d’un client. Le mock server reçoit le POST et l’enregistre. On vérifie côté mock que le POST est bien parti, avec le bon corps et les bons en-têtes.
Les deux cas partagent la même Page utilitaire, mais donnent des scénarios très différents. Ne pas les mélanger dans un même it.
Le mock server : Mockoon en side-car
Mockoon est un mock HTTP open-source, avec une CLI (mockoon-cli) et une image Docker officielle (mockoon/cli). On lui donne un fichier d’environnement JSON — le plus simple est de le composer dans l’application desktop Mockoon, puis de l’exporter — qui décrit les routes à servir : ici, une route POST /received qui répond 200.
Chaque mock lancé expose aussi une API d’admin sous /mockoon-admin/, sur le même port que les routes. C’est le cœur de l’usage en test : GET /mockoon-admin/logs renvoie les transactions reçues (requête et réponse), POST /mockoon-admin/logs/purge les efface. Dans les versions récentes de la CLI, dont la 9.9.0 épinglée plus bas, cette API est toujours protégée par un jeton : il se fixe au lancement avec --admin-api-token ou la variable MOCKOON_ADMIN_API_TOKEN (sinon Mockoon en génère un et l’affiche dans ses logs), et s’envoie dans l’en-tête Authorization: Bearer <jeton>.
Alternatives selon le besoin :
- WireMock — plus riche (matchers avancés, scénarios stateful), mais tourne sur la JVM, plus lourd à faire tourner.
- Serveur PHP maison —
php -S 0.0.0.0:9000 mock.phpavec un script d’une trentaine de lignes suffit quand on ne veut que capturer un POST unique et le relire depuis le disque.
Extrait de workflow GitHub Actions qui lance Mockoon en side-car :
env:
# Jeton jetable : le mock ne vit que le temps du job.
MOCKOON_ADMIN_API_TOKEN: prestaflow-ci-only
# Secret de signature de test, configuré aussi dans le module côté boutique.
STRIPE_WEBHOOK_SECRET: whsec_prestaflow_ci
steps:
- name: Start Mockoon
run: |
docker network create prestashop
docker run -d --name mock --network prestashop \
-p 9000:9000 \
-e MOCKOON_ADMIN_API_TOKEN \
-v ${{ github.workspace }}/tests/mock:/data:ro \
mockoon/cli:9.9.0 --data /data/mock.json --port 9000
Le réseau Docker est indispensable : MariaDB et Flashlight démarrent ensuite sur ce même réseau prestashop, avec --network prestashop, exactement comme dans l’annexe emails (sans refaire le docker network create). La boutique joint alors le mock sous le nom de son conteneur, http://mock:9000 ; PrestaFlow, qui tourne sur le runner, le joint par le port publié, http://localhost:9000. Sans réseau commun, mock ne se résout pas depuis le conteneur PrestaShop et le POST sortant du cas B n’arrive jamais.
La version de l’image est épinglée : le format des transactions renvoyées par l’API d’admin peut évoluer d’une version majeure à l’autre.
La Page utilitaire
Créez tests/prestaflow/Pages/v8/Support/Webhook/Page.php. Comme la Mailbox de l’annexe emails, c’est une Page sans URL du site : elle regroupe les appels HTTP et les attentes.
<?php
namespace Tests\Pages\v8\Support\Webhook;
use PrestaFlow\Library\Pages\v8\FrontOffice\Page as BasePage;
class Page extends BasePage
{
/**
* POST serveur à serveur, comme le ferait le tiers : curl, pas Chrome.
*
* @return array{status: int, body: string}
*/
public function postJson(string $url, string $payload, array $headers = []): array
{
$httpHeaders = ['Content-Type: application/json'];
foreach ($headers as $name => $value) {
$httpHeaders[] = $name . ': ' . $value;
}
return $this->request('POST', $url, $httpHeaders, $payload);
}
/**
* En-tête Stripe-Signature : t=<timestamp>,v1=<HMAC-SHA256 hex de "{t}.{payload}">,
* calculé avec le secret de signature de l'endpoint (whsec_…).
*/
public function stripeSignature(string $payload, ?string $secret = null, ?int $timestamp = null): string
{
$secret ??= $this->env('STRIPE_WEBHOOK_SECRET');
$timestamp ??= time();
return 't=' . $timestamp . ',v1=' . hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
}
public function purgeMockRequests(): void
{
$this->mockAdmin('POST', '/mockoon-admin/logs/purge');
}
/**
* Requêtes reçues par Mockoon sur un chemin donné.
*/
public function mockRequests(string $path): array
{
$response = $this->mockAdmin('GET', '/mockoon-admin/logs?limit=100');
$transactions = json_decode($response['body'], true);
$requests = [];
foreach (is_array($transactions) ? $transactions : [] as $transaction) {
if (($transaction['request']['urlPath'] ?? null) === $path) {
$requests[] = $transaction['request'];
}
}
return $requests;
}
/**
* Attend qu'une requête arrive sur $path (le module peut poster en différé).
*/
public function waitForMockRequest(string $path, int $timeout = 10000): array
{
$this->waitUntil(
fn () => count($this->mockRequests($path)) > 0,
$timeout,
sprintf('Aucune requête reçue par le mock sur %s', $path)
);
return $this->mockRequests($path)[0];
}
public function requestHeader(array $request, string $name): ?string
{
// Mockoon liste les en-têtes sous la forme [['key' => ..., 'value' => ...], ...].
foreach ($request['headers'] ?? [] as $header) {
if (strcasecmp($header['key'] ?? '', $name) === 0) {
return $header['value'] ?? null;
}
}
return null;
}
private function mockAdmin(string $method, string $path): array
{
$baseUrl = rtrim($this->env('MOCKOON_URL', 'http://localhost:9000'), '/');
return $this->request($method, $baseUrl . $path, [
'Authorization: Bearer ' . $this->env('MOCKOON_ADMIN_API_TOKEN'),
]);
}
private function request(string $method, string $url, array $headers = [], ?string $body = null): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
return ['status' => $status, 'body' => is_string($responseBody) ? $responseBody : ''];
}
private function env(string $name, string $default = ''): string
{
// .env chargé par PrestaFlow → $_ENV ; variables posées par la CI → getenv().
$value = $_ENV[$name] ?? getenv($name);
return is_string($value) && $value !== '' ? $value : $default;
}
}
importPage() cherche la Page dans le dossier de la version majeure de la boutique. Pour que les mêmes suites tournent aussi sur PrestaShop 9, ajoutez tests/prestaflow/Pages/v9/Support/Webhook/Page.php, qui se contente d’hériter :
<?php
namespace Tests\Pages\v9\Support\Webhook;
use Tests\Pages\v8\Support\Webhook\Page as WebhookPage;
class Page extends WebhookPage
{
}
Deux détails comptent. waitUntil() est l’attente par polling de PrestaFlow : elle réévalue la condition toutes les 100 ms et lève une exception au-delà du délai, ce qui fait échouer le it avec un message clair. Et l’extension PHP curl doit être disponible sur la machine qui lance les tests (c’est le cas sur les runners GitHub).
Cas A : la boutique reçoit un webhook Stripe
Objectif : vérifier que mymodule_stripe traite un payment_intent.succeeded en passant la commande de « En attente de paiement » à « Paiement accepté ».
Séquence :
- Setup — une commande fixture de référence
WHKTEST01, en attente de paiement, créée par un init-script comme dans l’annexe fixtures et seed data. Le même init-script pose dans la configuration du module le secret de signature de test (celui deSTRIPE_WEBHOOK_SECRET, passé au conteneur PrestaShop avec-e). - Trigger — le scénario POST l’événement Stripe simulé, signé, vers le contrôleur du module.
- Assertion — le scénario navigue vers la commande en BO et vérifie le nouveau statut.
Créez tests/prestaflow/Suites/StripeWebhook.php :
<?php
namespace Tests\Suites;
use PrestaFlow\Library\Expects\Expect;
use PrestaFlow\Library\Tests\TestsSuite;
class StripeWebhook extends TestsSuite
{
public function init()
{
$this->importPage('Support\Webhook', domain: 'Tests');
$this->importPage('BackOffice\Login');
$this->importPage('BackOffice\Orders');
$this->importPage('BackOffice\OrderView');
extract($this->pages);
// L'URL que le module affiche dans sa configuration, celle qu'on déclare chez Stripe.
$webhookUrl = $this->getGlobals()['FO']['URL']
. 'index.php?fc=module&module=mymodule_stripe&controller=webhook';
// Payload versionné, qui référence la commande fixture WHKTEST01 dans ses metadata.
$payload = (string) file_get_contents(getcwd() . '/tests/fixtures/stripe/payment_intent_succeeded.json');
$this
->describe('mymodule_stripe traite payment_intent.succeeded')
->it('rejette un webhook mal signé', function () use ($supportWebhookPage, $webhookUrl, $payload) {
$response = $supportWebhookPage->postJson($webhookUrl, $payload, [
'Stripe-Signature' => $supportWebhookPage->stripeSignature($payload, 'whsec_mauvais_secret'),
]);
// 400 est le code que recommande Stripe ; adaptez-le à votre module.
Expect::that($response['status'])->isTheSameAs(400);
})
->it('accepte un webhook correctement signé', function () use ($supportWebhookPage, $webhookUrl, $payload) {
$response = $supportWebhookPage->postJson($webhookUrl, $payload, [
'Stripe-Signature' => $supportWebhookPage->stripeSignature($payload),
]);
Expect::that($response['status'])->isTheSameAs(200);
})
->it('passe la commande en paiement accepté', function () use ($backOfficeLoginPage, $backOfficeOrdersPage, $backOfficeOrderViewPage) {
$backOfficeLoginPage->goToPage('index');
$backOfficeLoginPage->login();
$backOfficeOrdersPage->goTo();
$backOfficeOrdersPage->filterByReference('WHKTEST01');
$backOfficeOrdersPage->openOrder(1);
// Libellé d'une boutique en anglais (Flashlight n'installe que l'anglais).
Expect::that($backOfficeOrderViewPage->getCurrentStatus())->equals('Payment accepted');
});
}
}
Le corps envoyé est exactement la chaîne signée : on lit le fichier une fois et on ne le ré-encode jamais, sinon un espace ou un ordre de clés différent casse la signature. Le payload est un dump anonymisé pris de la doc Stripe ou d’un événement de test. On le stocke dans tests/fixtures/stripe/ et on le versionne.
Si le module traite l’événement en différé (file d’attente, cron), remplacez l’assertion directe par une attente : waitUntil() autour d’un rechargement de la fiche commande, plutôt qu’un sleep().
Cas B : la boutique envoie un webhook
Objectif : vérifier que mymodule_notify poste bien vers l’endpoint client configuré à chaque création de commande, avec les bons champs.
Séquence :
- Config — le module est configuré pour pointer sur
http://mock:9000/received, par un init-script (php bin/console prestashop:config set MYMODULE_NOTIFY_URL --value http://mock:9000/received, disponible à partir de PrestaShop 8) ou par sa page de configuration en BO. - Reset — vider les transactions capturées par Mockoon avant de commencer.
- Trigger — un checkout front complet, qui doit générer le POST sortant.
- Attente + assertion — interroger l’API d’admin Mockoon jusqu’à l’arrivée du POST, puis vérifier corps et en-têtes.
Pour le checkout, on réutilise le scénario CheckoutOrder fourni par la bibliothèque : il commande en front, puis stocke la référence de la commande, qu’on relit avec $this->retrieve('orderReference').
Sur une boutique Flashlight neuve, les modules de paiement sont réservés au Royaume-Uni, le pays d’installation de l’image : avec une adresse française, CheckoutOrder ne trouverait aucun moyen de paiement, et aucune commande ne serait créée. Avant de lancer la suite, l’init-script tests/flashlight-init/30-enable-payment.sh du module psflowdemo active la France et ouvre les modules de paiement à tous les pays actifs.
Créez tests/prestaflow/Suites/NotifyWebhook.php :
<?php
namespace Tests\Suites;
use PrestaFlow\Library\Expects\Expect;
use PrestaFlow\Library\Scenarios\CheckoutOrder;
use PrestaFlow\Library\Tests\TestsSuite;
class NotifyWebhook extends TestsSuite
{
public function init()
{
$this->importPage('Support\Webhook', domain: 'Tests');
extract($this->pages);
$this
->describe('mymodule_notify prévient l\'ERP à chaque commande')
->it('part d\'un mock vide', function () use ($supportWebhookPage) {
$supportWebhookPage->purgeMockRequests();
Expect::that($supportWebhookPage->mockRequests('/received'))->isEmpty();
})
->scenario(CheckoutOrder::class, [
// Boutique Flashlight : anglais uniquement, client de démo de la boutique.
'locale' => 'en',
'customerPassword' => $this->getGlobals()['FO']['PASSWD'],
])
->it('poste la commande vers l\'ERP', function () use ($supportWebhookPage) {
// Le POST peut partir en différé (file d'attente du module) : on attend.
$request = $supportWebhookPage->waitForMockRequest('/received', 10000);
$body = json_decode($request['body'] ?? '', true);
Expect::that($body['order_reference'] ?? '')->equals($this->retrieve('orderReference'));
Expect::that(preg_match('/^\d+\.\d{2}$/', (string) ($body['amount_ttc'] ?? '')))->isTheSameAs(1);
Expect::that((string) $supportWebhookPage->requestHeader($request, 'X-Signature'))->isNotEmpty();
});
}
}
Les champs order_reference, amount_ttc et l’en-tête X-Signature sont ceux de notre module d’exemple : remplacez-les par le contrat réel de votre module. Attention aussi : l’API d’admin masque la valeur de quelques en-têtes sensibles (Authorization, Cookie, X-Api-Key…). Si votre module signe dans l’un d’eux, vérifiez sa présence, pas sa valeur.
Cas particulier : la signature du webhook
Stripe, GitHub, Slack, Shopify et beaucoup d’autres signent leurs webhooks avec un HMAC-SHA256 calculé avec un secret partagé, sur le corps brut — parfois préfixé d’un horodatage, comme chez Stripe ("{t}.{payload}") ou Slack. Un module correctement écrit rejette tout POST sans signature valide — vos tests aussi vont être rejetés si vous ne faites rien.
Trois options selon le contexte :
- Désactiver la vérification de signature en environnement de test — le module lit une variable d’environnement (
MYMODULE_STRIPE_VERIFY_SIG=0) qui court-circuite la vérification. Simple, mais fragile : ça teste un chemin de code qui n’existe pas en prod. - Générer la signature côté scénario — calcul HMAC dans la Page
Webhook, avec le secret de test partagé entre la CI et la configuration du module. Fidèle, un peu plus lourd, c’est l’option recommandée — celle du cas A. - Passer par la CLI Stripe —
stripe listen --forward-to <url du webhook>affiche un secretwhsec_…propre à la session et relaie les événements de votre compte de test, questripe trigger payment_intent.succeededdéclenche à la demande. Bien pour un test d’intégration de bout en bout avec un vrai compte Stripe de test ; inutile dans une CI isolée.
La signature Stripe embarque son horodatage : par défaut, les bibliothèques Stripe refusent un écart de plus de cinq minutes. Calculez-la juste avant l’envoi, comme dans le cas A, plutôt que de la figer dans une fixture.
Notes
- Timing — entre l’action UI et l’arrivée du webhook (ou son traitement s’il passe par une queue), il y a toujours une latence. Un
sleep(N)naïf est instable. Attendez la condition elle-même, avecwaitUntil()ou le poll manuel vu dans l’annexe timeouts et attentes. - Reset entre suites — au début de chaque suite, vider les transactions capturées côté Mockoon (
POST /mockoon-admin/logs/purge) et purger les logs webhook côté module. Sans ça, la suite N voit les événements de la suite N-1 et donne des faux positifs. Mockoon ne garde par défaut que les 100 dernières transactions en mémoire (--max-transaction-logs). - Payloads sensibles — les dumps de vrais webhooks contiennent souvent des montants, des emails, des adresses. Vos JSON de fixture ne doivent jamais contenir de données réelles. Anonymisez systématiquement avant commit, et faites tourner un scanner de secrets sur le dossier
tests/fixtures/. - Un webhook = une intention — ne mélangez pas dans un même
itla vérification du parsing, celle de l’idempotence et celle de l’effet métier. Troisit, trois assertions ciblées : quand un test rouge sort, on sait tout de suite quelle brique casse.
Dans la Série PrestaFlow — article 32 sur 35