fix(borne): persiste et garde le mode de consommation (corrige 422 INVALID_SERVICE_MODE au paiement)
All checks were successful
CI / secret-scan (pull_request) Successful in 10s
CI / php-lint (pull_request) Successful in 24s
CI / static-tests (pull_request) Successful in 48s
CI / js-tests (pull_request) Successful in 30s
CI / php-lint (push) Successful in 26s
CI / static-tests (push) Successful in 1m11s
CI / js-tests (push) Successful in 34s
CI / secret-scan (push) Successful in 11s

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.
This commit is contained in:
Imugiii 2026-06-22 13:34:18 +00:00
parent 545aa19cf1
commit 88c987c2b2
4 changed files with 112 additions and 11 deletions

View file

@ -6,12 +6,38 @@
* element with [data-mode-badge] on the page. * element with [data-mode-badge] on the page.
* - Sync the cart item count into any element with [data-cart-count]. * - Sync the cart item count into any element with [data-cart-count].
* - Handle the mode query-string on page load (welcome -> categories handoff). * - 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 this module in every page that has a header.
*/ */
import { getMode, setMode, getCartCount } from './state.js'; 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. * Reads ?mode= from the current URL and persists it if present.
* Called once on DOMContentLoaded so that the welcome -> categories * Called once on DOMContentLoaded so that the welcome -> categories
@ -20,7 +46,7 @@ import { getMode, setMode, getCartCount } from './state.js';
function syncModeFromURL() { function syncModeFromURL() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const modeParam = params.get('mode'); const modeParam = params.get('mode');
if (modeParam === 'sur-place' || modeParam === 'a-emporter') { if (VALID_MODES.includes(modeParam)) {
setMode(modeParam); setMode(modeParam);
} }
} }
@ -29,8 +55,7 @@ function syncModeFromURL() {
* Renders the human-readable mode label into every [data-mode-badge] element. * Renders the human-readable mode label into every [data-mode-badge] element.
*/ */
function renderModeBadge() { function renderModeBadge() {
const mode = getMode(); const label = modeLabel(getMode());
const label = mode === 'a-emporter' ? 'A emporter' : 'Sur place';
document.querySelectorAll('[data-mode-badge]').forEach(el => { document.querySelectorAll('[data-mode-badge]').forEach(el => {
el.textContent = label; el.textContent = label;
}); });
@ -48,9 +73,17 @@ export function refreshCartBadge() {
}); });
} }
/* Initialise on DOM ready */ /* 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', () => { document.addEventListener('DOMContentLoaded', () => {
syncModeFromURL(); 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(); renderModeBadge();
refreshCartBadge(); refreshCartBadge();
}); });
}

View file

@ -60,8 +60,17 @@ async function doSubmit(serviceTag) {
function startCheckout() { function startCheckout() {
if (checkingOut) return; 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; checkingOut = true;
if (getMode() === 'sur-place') { if (mode === 'sur-place') {
openChevalet(tag => doSubmit(tag), () => { checkingOut = false; }); openChevalet(tag => doSubmit(tag), () => { checkingOut = false; });
} else { } else {
doSubmit(''); doSubmit('');
@ -176,7 +185,8 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
if (recap) { 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 = ` recap.innerHTML = `
<p class="payment-recap__mode">${escHtml(modeLabel)}</p> <p class="payment-recap__mode">${escHtml(modeLabel)}</p>
<p class="payment-recap__items">${items.length} article${items.length > 1 ? 's' : ''}</p> <p class="payment-recap__items">${items.length} article${items.length > 1 ? 's' : ''}</p>

View file

@ -177,6 +177,11 @@
</nav> </nav>
</main> </main>
<!-- nav.js : persiste le mode de consommation recu en ?mode= depuis l'accueil
(syncModeFromURL) AVANT que l'utilisateur ne suive une carte vers products.html.
Sans ce script, le mode n'atteint jamais localStorage et le paiement part en
service_mode null (422). C'est la 1re page post-accueil, elle doit donc le charger. -->
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/a11y.js"></script> <script type="module" src="assets/js/a11y.js"></script>
</body> </body>
</html> </html>

53
tests/js/nav.test.js Normal file
View file

@ -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/);
});