PrestaFlow : asserter sur le contenu d'une facture PDF
Décor
Beaucoup de modules PS touchent au PDF généré par PrestaShop : un module fiscal ajoute une mention légale sur la facture, un module de livraison imprime un bon de livraison, un module de fidélité colle un code cadeau sur l’avoir, un module marketplace régénère une facture par vendeur. Tout ce contenu vit à côté du HTML — les assertions front classiques de PrestaFlow ne voient rien de ce qui finit dans le PDF.
Vérifier que le bon texte s’y retrouve demande deux choses : récupérer le fichier PDF produit par PS (depuis le BO, depuis une pièce jointe email, depuis un stockage S3), et extraire son texte pour asserter dessus. Le second point est ce qui bloquait jusqu’ici — installer un parser, mapper une API, gérer le cache — et c’est exactement ce que la 1.6.0 rend trivial.
Support\Pdf : le wrapper livré avec la 1.6.0
Depuis la 1.6.0, la lib embarque PrestaFlow\Library\Support\Pdf — un wrapper mince autour de smalot/pdfparser qui expose exactement ce dont un scénario a besoin, rien de plus :
use PrestaFlow\Library\Support\Pdf;
$pdf = Pdf::fromFile('/tmp/facture.pdf'); // ou fromString($binary)
$pdf->contains('TVA'); // bool — case-insensitive par défaut
$pdf->contains('TVA', caseSensitive: true); // strict
$pdf->matches('/#([A-Z]+\d{6})/', 1); // groupe de capture 1 → ['IN000042']
$pdf->pages(); // string[] — 1 entrée par page
$pdf->pageCount(); // int
$pdf->text(); // tout le PDF, pages séparées par \n\n
Deux entrées : fromFile() pour un chemin disque, fromString() pour un binaire déjà en mémoire (typique quand on a fetché le PDF via HTTP ou récupéré une pièce jointe d’un mail). Le parser n’est instancié qu’à la première lecture, et les résultats sont mis en cache par instance — on peut enchaîner contains, matches puis pages() sans re-parser.
Rien à installer côté projet : smalot/pdfparser ^2.11 est une dépendance de la lib.
Récupérer le PDF de facture depuis le BackOffice
Le pattern le plus courant : on vient de passer une commande dans le scénario et on veut vérifier le contenu de sa facture. Deux points à connaître :
- PrestaShop ne crée la facture qu’au passage dans un statut qui la génère, comme « Paiement accepté ». Une commande « En attente du paiement par chèque » ou « par virement bancaire » n’a pas encore de facture.
- Le téléchargement exige un jeton de sécurité. Sur PS 8 et 9, le lien de la fiche commande pointe vers
/sell/orders/{id}/generate-invoice-pdfavec le jeton_tokende la session. L’ancienne URLindex.php?controller=AdminPdf&submitAction=generateInvoicePDF&id_order={id}exige elle aussi son jeton (token=…, propre au contrôleurAdminPdf) : sans lui, PrestaShop refuse la requête au lieu de renvoyer le PDF.
Plutôt que de calculer ce jeton, on lit le lien déjà présent sur la fiche commande, jeton compris. Créez tests/prestaflow/Pages/v9/Support/Invoice/Page.php — une Page utilitaire qui télécharge le PDF et le pose sur disque pour que le scénario puisse l’attaquer avec Pdf::fromFile() :
<?php
namespace Tests\Pages\v9\Support\Invoice;
use PrestaFlow\Library\Pages\v9\BackOffice\Page as BasePage;
class Page extends BasePage
{
public function defineSelectors()
{
return [
// Lien « Voir la facture » de la fiche commande (PS 8 et 9).
// Son href contient déjà le jeton _token de la session BO.
'invoiceLink' => 'a[href*="generate-invoice-pdf"]',
];
}
/**
* À appeler depuis la fiche commande du BO.
*/
public function downloadInvoice(string $destPath): string
{
$this->waitVisible($this->getSelector('invoiceLink'), 10000);
// fetch() depuis la page : même origine, donc les cookies de la session
// BO partent avec la requête. Le binaire revient encodé en base64,
// car evaluate() ne transporte que du JSON.
$base64 = $this->getPage()->evaluate(sprintf(
'(async function(){'
. 'var r=await fetch(document.querySelector(%s).href,{credentials:"include"});'
. 'if(!r.ok)return null;'
. 'var b=new Uint8Array(await r.arrayBuffer()),s="";'
. 'for(var i=0;i<b.length;i+=32768){s+=String.fromCharCode.apply(null,b.subarray(i,i+32768));}'
. 'return btoa(s);})()',
json_encode($this->getSelector('invoiceLink'))
))->getReturnValue(15000);
if (!is_string($base64)) {
throw new \RuntimeException('Téléchargement de la facture impossible');
}
file_put_contents($destPath, base64_decode($base64));
return $destPath;
}
}
Volontairement mince — la Page ne fait qu’une chose : télécharger et écrire sur disque. C’est le scénario qui attaque le fichier avec Pdf::fromFile(). importPage() cherche la Page dans le dossier de la version majeure testée : sur PS 8, placez-la aussi dans v8/, avec le namespace et la classe parente v8.
Un scénario complet : commande + facture + assertion
Objectif : le module psflowdemo_taxexempt doit ajouter la mention « Exonération de TVA, article 262 I du CGI » (celle des exportations hors UE ; l’article 262 ter I vise les livraisons intracommunautaires) sur la facture des clients marqués « hors UE ». Le scénario passe une commande avec un client marqué, la passe en « Paiement accepté » pour que PrestaShop génère la facture, télécharge celle-ci et vérifie la présence de la mention.
<?php
namespace Tests\Suites;
use PrestaFlow\Library\Expects\Expect;
use PrestaFlow\Library\Support\Pdf;
use PrestaFlow\Library\Tests\TestsSuite;
class InvoiceTaxExemptMention extends TestsSuite
{
public function init()
{
$this->importPage('BackOffice\Orders');
$this->importPage('BackOffice\OrderView');
$this->importPage('Support\Invoice', domain: 'Tests');
extract($this->pages);
$pdfPath = sys_get_temp_dir() . '/facture-' . uniqid() . '.pdf';
$this
->describe('Facture d\'un client hors UE — mention légale ajoutée')
->scenario(\PrestaFlow\Library\Scenarios\CheckoutOrder::class, [
// Pré-requis fixture : ce client existe, avec ce mot de passe et une
// adresse US en base (cf. annexe fixtures-seed-data), ce qui déclenche le module.
'customerEmail' => 'client-hors-ue@example.test',
'customerPassword' => 'PrestaFlow2026!',
])
->it('passe la commande en « Paiement accepté », ce qui génère la facture', function () use ($backOfficeOrdersPage, $backOfficeOrderViewPage) {
// CheckoutOrder s'est déjà connecté au BO et a stocké la référence
// de commande sous 'orderReference'.
$backOfficeOrdersPage->goTo();
$backOfficeOrdersPage->filterByReference($this->retrieve('orderReference'));
$backOfficeOrdersPage->openOrder(1);
// Libellé du statut dans la langue de l'employé BO.
$backOfficeOrderViewPage->updateStatus('Paiement accepté');
Expect::that($backOfficeOrderViewPage->getCurrentStatus())->contains('Paiement accepté');
})
->it('télécharge la facture PDF de la commande', function () use ($supportInvoicePage, $pdfPath) {
$supportInvoicePage->downloadInvoice($pdfPath);
Expect::that(is_file($pdfPath))->isTheSameAs(true);
Expect::that(filesize($pdfPath))->isGreaterThan(1000); // pas un PDF vide
})
->it('le PDF contient la mention légale d\'exonération', function () use ($pdfPath) {
$pdf = Pdf::fromFile($pdfPath);
Expect::that($pdf->contains('Exonération de TVA'))->isTheSameAs(true);
Expect::that($pdf->contains('article 262 I du CGI'))->isTheSameAs(true);
})
->it('le PDF ne mentionne aucune ligne TVA à taux non nul', function () use ($pdfPath) {
$pdf = Pdf::fromFile($pdfPath);
// Aucune ligne "TVA XX,XX %" avec un taux supérieur à zéro (exemption totale).
$taux = $pdf->matches('/TVA\s+(\d+(?:[,.]\d+)?)\s*%/', 1);
$tauxNonNuls = array_filter($taux, fn ($t) => (float) str_replace(',', '.', $t) > 0);
Expect::that(count($tauxNonNuls))->isTheSameAs(0);
})
->it('le PDF porte un numéro de facture au bon format', function () use ($pdfPath) {
$pdf = Pdf::fromFile($pdfPath);
// Préfixe de facturation (PS_INVOICE_PREFIX) : « #IN » par défaut sur une
// boutique installée en anglais, « #FA » en français.
$prefix = $_ENV['PS_INVOICE_PREFIX'] ?? getenv('PS_INVOICE_PREFIX') ?: '#IN';
// Préfixe suivi du numéro sur six chiffres, ex. #IN000042
$numeros = $pdf->matches('/' . preg_quote($prefix, '/') . '\d{6}/');
Expect::that(count($numeros))->isGreaterThanOrEqualTo(1);
});
}
}
Après les étapes du scénario CheckoutOrder, cinq it : le premier prépare la facture, les quatre suivants répondent aux questions qu’on se pose sur un PDF généré :
- Le PDF a bien été produit (fichier présent, non vide)
- Le texte attendu est dedans (mention légale positive)
- Le texte interdit n’est pas dedans (assertion négative sur les lignes TVA)
- Le numéro de facture a le bon format : le préfixe configuré côté PS, suivi de six chiffres
Les deux contains sont insensibles à la casse par défaut — l’extraction PDF renvoie souvent tout en majuscules pour les mentions légales, et ce qu’on veut asserter est lisible côté humain, pas octet par octet. Limite à connaître : cette insensibilité repose sur stripos(), qui ne traite que l’ASCII. contains('exonération') trouve « EXONéRATION » mais pas « EXONÉRATION » ; pour une mention en capitales accentuées, cherchez-la telle qu’elle est imprimée, ou passez par matches() avec le drapeau /iu.
Utiliser le PDF depuis une pièce jointe email
Si le PDF vous arrive par mail (facture attachée à l’email de confirmation), pas besoin du détour BO — on récupère le binaire depuis Mailpit et on passe par fromString(). Le pont avec l’annexe « tester les emails envoyés » tient en trois lignes :
$message = $supportMailboxPage->findByRecipient('client@example.test');
$binary = $supportMailboxPage->downloadPdfAttachment($message['ID']);
$pdf = Pdf::fromString($binary);
Expect::that($pdf->contains('Exonération de TVA'))->isTheSameAs(true);
downloadPdfAttachment() n’est pas dans la Page Mailbox de l’annexe emails — c’est un ajout naturel, qui s’appuie sur la méthode getAttachments() de cette annexe et interroge GET /api/v1/message/{ID}/part/{PartID} (l’API Mailpit renvoie le binaire brut de la pièce jointe). On cherche la pièce jointe par type plutôt que par nom : PrestaShop nomme le fichier d’après le numéro de facture (#IN000042.pdf, par exemple).
public function downloadPdfAttachment(string $messageId): string
{
foreach ($this->getAttachments($messageId) as $attachment) {
if (($attachment['ContentType'] ?? '') === 'application/pdf') {
return $this->httpGet($this->mailpitUrl . '/api/v1/message/' . $messageId . '/part/' . $attachment['PartID']);
}
}
throw new \RuntimeException('Aucune pièce jointe PDF dans le message ' . $messageId);
}
matches() : capturer plutôt que juste vérifier
contains() répond à “la mention est-elle là”. matches() répond à “quelle est la valeur ?” — et c’est souvent ce qu’on veut asserter finement.
// Total TTC affiché sur la facture, en float.
// Drapeau u : \s reconnaît alors les espaces insécables des montants formatés.
$totaux = $pdf->matches('/Total\s+TTC\s+([\d\s,]+)\s*€/u', 1);
$totalTtc = (float) str_replace(',', '.', preg_replace('/[^\d,]/', '', $totaux[0]));
Expect::that($totalTtc)->isBetween(120.00, 121.00);
// Toutes les références de produits listées sur la facture.
// matches() renvoie un tableau : contains() attend une chaîne, on teste avec in_array().
$refs = $pdf->matches('/Réf\.\s*:\s*([A-Z0-9\-]+)/u', 1);
Expect::that(in_array('SKU-DEMO-42', $refs, true))->isTheSameAs(true);
La regex s’applique à text() (concaténation de toutes les pages avec \n\n comme séparateur). Si vous voulez vérifier qu’une mention figure spécifiquement sur la première page (la mention d’exonération, par exemple), utilisez pages()[0] :
Expect::that($pdf->pages()[0])->contains('Exonération de TVA');
Interaction avec les autres annexes
- Tester les emails envoyés — le compagnon naturel. Cette annexe capture un mail avec sa pièce jointe PDF ; celle-ci assert sur le contenu du PDF. Combinées, elles couvrent le flow “la commande envoie un mail au client avec la bonne facture dedans” de bout en bout.
- Tester un module tiers — le cas d’usage typique où la question “le module a-t-il bien modifié la facture ?” revient : module fiscal, module de livraison, module marketplace, module d’avoir.
Pdftransforme cette question en 2 lignes de test. - TDD — bug remonté “la mention d’exonération de TVA a disparu après update PS 9” → scénario de reproduction (commande client US +
$pdf->contains('Exonération')) → rouge → fix côté module → vert. Le scénario reste comme non-régression. - Fixtures — le client marqué « hors UE » (adresse US + groupe fiscal exempté) fait partie de l’état initial nécessaire. Le poser en niveau 1 (init-script Flashlight) permet au scénario de démarrer avec le bon terrain sans passer une minute à créer client + adresse à chaque run.
Notes
Dans la Série PrestaFlow — article 29 sur 35