From 88c987c2b23ab00d1ac9ee7266a887595cf95b61 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 22 Jun 2026 13:34:18 +0000 Subject: [PATCH] fix(borne): persiste et garde le mode de consommation (corrige 422 INVALID_SERVICE_MODE au paiement) Le paiement borne echouait en POST /api/orders -> 422 INVALID_SERVICE_MODE car le mode de consommation (sur place / a emporter) n'atteignait jamais localStorage : la 1re page post-accueil (categories.html) ne chargeait pas nav.js, donc le ?mode= recu de l'accueil n'etait pas persiste, et ses cartes statiques pointaient vers products.html sans &mode=. Au paiement, mapServiceMode(null) -> service_mode null -> rejet serveur ; le badge affichait en plus un faux 'Sur place' qui masquait l'absence de mode. - categories.html : charge nav.js -> syncModeFromURL persiste le mode des l'etape categories (cause racine du 422 ; sans ca la garde ci-dessous bouclerait). - nav.js : needsModeRedirect (helper pur) renvoie a l'accueil si une page hors accueil n'a pas de mode valide memorise ; modeLabel ne ment plus (vide si inconnu) ; listener DOMContentLoaded garde derriere typeof document -> module importable en test pur. - page-payment.js : garde finale dans startCheckout (pas de soumission sans mode valide, retour accueil, panier conserve) + libelle recap honnete. - tests/js/nav.test.js : table de verite des helpers + regression (categories.html charge nav.js). Revue bmad-compliance : regression de flux normal identifiee (must_fix) corrigee. 92 tests JS verts. --- src/public/borne/assets/js/nav.js | 51 +++++++++++++++++---- src/public/borne/assets/js/page-payment.js | 14 +++++- src/public/borne/categories.html | 5 ++ tests/js/nav.test.js | 53 ++++++++++++++++++++++ 4 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 tests/js/nav.test.js diff --git a/src/public/borne/assets/js/nav.js b/src/public/borne/assets/js/nav.js index 5b431b8..74fd5ea 100644 --- a/src/public/borne/assets/js/nav.js +++ b/src/public/borne/assets/js/nav.js @@ -6,12 +6,38 @@ * element with [data-mode-badge] on the page. * - Sync the cart item count into any element with [data-cart-count]. * - Handle the mode query-string on page load (welcome -> categories handoff). + * - Guard : une page au-dela de l'accueil EXIGE un mode de consommation. Sans + * mode (ex. localStorage vide en cours de session), la borne POSTerait + * service_mode:null et la commande est rejetee en 422. Sur une page profonde + * sans mode, on renvoie vers l'accueil pour que le mode soit (re)choisi. * * Import this module in every page that has a header. */ import { getMode, setMode, getCartCount } from './state.js'; +const VALID_MODES = ['sur-place', 'a-emporter']; + +/** Libelle humain d'un mode ; chaine vide si aucun mode valide (ne ment pas). */ +export function modeLabel(mode) { + if (mode === 'a-emporter') return 'A emporter'; + if (mode === 'sur-place') return 'Sur place'; + return ''; +} + +/** + * Faut-il renvoyer vers l'accueil ? Vrai hors de l'ecran d'accueil quand aucun + * mode de consommation valide n'est memorise. Pur (teste sans DOM). Sans cette + * garde, atteindre une page de commande sans mode mene a service_mode:null -> 422. + * @param {string} pathname window.location.pathname + * @param {string|null} mode mode memorise + * @returns {boolean} + */ +export function needsModeRedirect(pathname, mode) { + const onWelcome = pathname === '/' || pathname.endsWith('/index.html'); + return !onWelcome && !VALID_MODES.includes(mode); +} + /** * Reads ?mode= from the current URL and persists it if present. * Called once on DOMContentLoaded so that the welcome -> categories @@ -20,7 +46,7 @@ import { getMode, setMode, getCartCount } from './state.js'; function syncModeFromURL() { const params = new URLSearchParams(window.location.search); const modeParam = params.get('mode'); - if (modeParam === 'sur-place' || modeParam === 'a-emporter') { + if (VALID_MODES.includes(modeParam)) { setMode(modeParam); } } @@ -29,8 +55,7 @@ function syncModeFromURL() { * Renders the human-readable mode label into every [data-mode-badge] element. */ function renderModeBadge() { - const mode = getMode(); - const label = mode === 'a-emporter' ? 'A emporter' : 'Sur place'; + const label = modeLabel(getMode()); document.querySelectorAll('[data-mode-badge]').forEach(el => { el.textContent = label; }); @@ -48,9 +73,17 @@ export function refreshCartBadge() { }); } -/* Initialise on DOM ready */ -document.addEventListener('DOMContentLoaded', () => { - syncModeFromURL(); - renderModeBadge(); - refreshCartBadge(); -}); +/* Initialise on DOM ready. Garde derriere typeof document pour rester importable + * en test pur (node sans jsdom) : modeLabel/needsModeRedirect n'ont alors aucun effet de bord. */ +if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => { + syncModeFromURL(); + // Mode absent sur une page profonde -> retour accueil (evite le 422 service_mode:null). + if (needsModeRedirect(window.location.pathname, getMode())) { + window.location.replace('index.html'); + return; + } + renderModeBadge(); + refreshCartBadge(); + }); +} diff --git a/src/public/borne/assets/js/page-payment.js b/src/public/borne/assets/js/page-payment.js index 0f68b24..6fc18d5 100644 --- a/src/public/borne/assets/js/page-payment.js +++ b/src/public/borne/assets/js/page-payment.js @@ -60,8 +60,17 @@ async function doSubmit(serviceTag) { function startCheckout() { if (checkingOut) return; + const mode = getMode(); + // Garde finale (defense en profondeur, en plus de la garde nav.js) : sans mode de + // consommation valide (ex. localStorage vide), ne PAS soumettre une commande a + // service_mode null (rejetee en 422 INVALID_SERVICE_MODE). On renvoie a l'accueil + // pour (re)choisir le mode, le panier etant conserve. + if (mode !== 'sur-place' && mode !== 'a-emporter') { + window.location.replace('index.html'); + return; + } checkingOut = true; - if (getMode() === 'sur-place') { + if (mode === 'sur-place') { openChevalet(tag => doSubmit(tag), () => { checkingOut = false; }); } else { doSubmit(''); @@ -176,7 +185,8 @@ document.addEventListener('DOMContentLoaded', () => { return; } if (recap) { - const modeLabel = getMode() === 'a-emporter' ? 'A emporter' : 'Sur place'; + const m = getMode(); + const modeLabel = m === 'a-emporter' ? 'A emporter' : (m === 'sur-place' ? 'Sur place' : ''); recap.innerHTML = `

${escHtml(modeLabel)}

${items.length} article${items.length > 1 ? 's' : ''}

diff --git a/src/public/borne/categories.html b/src/public/borne/categories.html index 70fbe4d..e7bea2e 100644 --- a/src/public/borne/categories.html +++ b/src/public/borne/categories.html @@ -177,6 +177,11 @@ + + diff --git a/tests/js/nav.test.js b/tests/js/nav.test.js new file mode 100644 index 0000000..eaca4df --- /dev/null +++ b/tests/js/nav.test.js @@ -0,0 +1,53 @@ +/* + * nav.test.js — Garde du mode de consommation borne (helpers PURS de nav.js). + * + * nav.js n'enregistre son listener DOMContentLoaded que derriere `typeof document`, + * donc l'import est sans effet de bord en node pur : on teste needsModeRedirect et + * modeLabel sans jsdom. Couvre la cause du 422 INVALID_SERVICE_MODE : atteindre une + * page de commande sans mode memorise (localStorage vide) doit renvoyer a l'accueil, + * et le badge ne doit jamais afficher un faux "Sur place" quand aucun mode n'est choisi. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { needsModeRedirect, modeLabel } from '../../src/public/borne/assets/js/nav.js'; + +/* --- modeLabel ----------------------------------------------------------- */ + +test('modeLabel: libelle humain ; vide si mode absent ou inconnu (ne ment pas)', () => { + assert.equal(modeLabel('sur-place'), 'Sur place'); + assert.equal(modeLabel('a-emporter'), 'A emporter'); + assert.equal(modeLabel(null), ''); + assert.equal(modeLabel(undefined), ''); + assert.equal(modeLabel('bidon'), ''); +}); + +/* --- needsModeRedirect --------------------------------------------------- */ + +test('needsModeRedirect: page profonde SANS mode valide -> redirige', () => { + assert.equal(needsModeRedirect('/payment.html', null), true); + assert.equal(needsModeRedirect('/products.html', undefined), true); + assert.equal(needsModeRedirect('/cart.html', 'bidon'), true); + assert.equal(needsModeRedirect('/categories.html', ''), true); +}); + +test('needsModeRedirect: mode valide -> pas de redirection', () => { + assert.equal(needsModeRedirect('/payment.html', 'sur-place'), false); + assert.equal(needsModeRedirect('/products.html', 'a-emporter'), false); +}); + +test('needsModeRedirect: ecran d accueil jamais redirige (le mode s y choisit)', () => { + assert.equal(needsModeRedirect('/', null), false); + assert.equal(needsModeRedirect('/index.html', null), false); + assert.equal(needsModeRedirect('/index.html', 'bidon'), false); +}); + +/* --- Cablage de la chaine de persistance (regression) -------------------- */ + +test('categories.html charge nav.js (sinon le mode recu en ?mode= n est jamais persiste)', () => { + // 1re page post-accueil : elle recoit ?mode= depuis index.html et DOIT le persister + // via syncModeFromURL. Sans nav.js ici, le mode n atteint pas localStorage et la + // garde renverrait en boucle un utilisateur legitime vers l accueil (regression revue). + const html = readFileSync(new URL('../../src/public/borne/categories.html', import.meta.url), 'utf8'); + assert.match(html, /assets\/js\/nav\.js/); +});