corentin_wakdo/tests/js/checkout.test.js
Imugiii 7835ae08c6
All checks were successful
CI / secret-scan (pull_request) Successful in 17s
CI / js-tests (pull_request) Successful in 25s
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 22s
CI / static-tests (push) Successful in 50s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 48s
CI / js-tests (push) Successful in 27s
feat(borne): soumission reelle de commande + ecran chevalet (P5 L4)
La borne POSTe desormais une vraie commande a l'API (avant : payment.html simulait
une redirection). Au paiement : le panier est traduit vers le contrat /api/orders,
la commande est creee puis encaissee (pay -> decrement stock RG-T20).

- checkout.js : traduction PURE panier -> contrat (produit/menu, format normal/maxi,
  selections [{menu_slot_id, product_id}] reconstruites depuis la composition + les
  slots re-fetches ; service_mode dine_in/takeaway ; service_tag chevalet). submitOrder
  fait create puis pay. Cle d'idempotence STABLE en sessionStorage : un retry reutilise
  la meme cle -> pas de commande doublon (RG-T19) ; effacee au succes.
- page-payment.js : recap + flux ; modale chevalet (sur-place, saisie du numero de
  table, maquette ecran 9) avec focus-trap/ESC ; verrou anti double-clic ; messages
  d'erreur. Cle d'idempotence fraiche a chaque visite paiement.
- page-confirmation.js : numero de commande REEL (via sessionStorage) au lieu d'un
  numero genere ; repli si visite directe.
- tests : checkout.test.js (traduction + submitOrder mocke). 64/64 verts.

Verifie e2e contre l'API reelle : commande creee + payee, stock decremente exactement
(Menu Le 280 + Big Mac x2 -> -5 Steak hache). Revue adversariale (9 findings) :
CRITIQUE (migration service_tag dupliquee) ecarte -- la colonne est livree par
0003_order_service_tag.sql (#55), juste non appliquee sur la stack dev (reconcilie
dans schema_migrations) ; MEDIUM idempotency + double-clic corriges ; aria-live
imbrique + commentaires obsoletes nettoyes. Differes (LOW notes) : format deduit du
supplement (theorique, le seed met toujours +150), selection sans slot silencieuse.
2026-06-19 17:59:49 +00:00

116 lines
5.1 KiB
JavaScript

/*
* Tests de checkout.js (P5 L4), node:test. checkout.js + state.js + data.js n'ont
* aucun acces DOM au chargement -> import statique. Cible : traduction PURE
* panier->contrat /api/orders (mapServiceMode, buildSelections, buildOrderItem,
* buildOrderPayload) + submitOrder (fetch + localStorage mockes).
*/
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
mapServiceMode, buildSelections, buildOrderItem, buildOrderPayload, submitOrder,
} from '../../src/public/borne/assets/js/checkout.js';
/* --- mapServiceMode ------------------------------------------------------ */
test('mapServiceMode: borne -> contrat (dine_in / takeaway), inconnu -> null', () => {
assert.equal(mapServiceMode('sur-place'), 'dine_in');
assert.equal(mapServiceMode('a-emporter'), 'takeaway');
assert.equal(mapServiceMode(null), null);
});
/* --- buildSelections ----------------------------------------------------- */
const slots = () => ([
{ id: 1, option_product_ids: [14, 15] }, // boisson
{ id: 16, option_product_ids: [22, 23] }, // accompagnement
{ id: 31, option_product_ids: [47] }, // sauce
]);
test('buildSelections: mappe les produits choisis a leur slot', () => {
const comp = { accompagnement: { id: 22 }, boisson: { id: 14 }, sauce: { id: 47 } };
assert.deepEqual(buildSelections(comp, slots()), [
{ menu_slot_id: 16, product_id: 22 },
{ menu_slot_id: 1, product_id: 14 },
{ menu_slot_id: 31, product_id: 47 },
]);
});
test('buildSelections: produit hors slots ignore ; composition vide -> []', () => {
assert.deepEqual(buildSelections({ boisson: { id: 999 } }, slots()), []);
assert.deepEqual(buildSelections(undefined, slots()), []);
});
/* --- buildOrderItem ------------------------------------------------------ */
test('buildOrderItem: produit simple', () => {
assert.deepEqual(buildOrderItem({ id: 14, type: 'produit', quantite: 3 }, {}), {
type: 'product', product_id: 14, quantity: 3,
});
});
test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
const menuItem = { id: 1, type: 'menu', quantite: 1, supplement_cents: 0,
composition: { accompagnement: { id: 22 }, boisson: { id: 14 } } };
const normal = buildOrderItem(menuItem, { 1: slots() });
assert.equal(normal.format, 'normal');
assert.equal(normal.menu_id, 1);
assert.equal(normal.selections.length, 2);
const maxi = buildOrderItem({ ...menuItem, supplement_cents: 150 }, { 1: slots() });
assert.equal(maxi.format, 'maxi');
});
/* --- buildOrderPayload --------------------------------------------------- */
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {
const cart = [{ id: 14, type: 'produit', quantite: 1 }];
const din = buildOrderPayload(cart, 'sur-place', '261', {}, 'key-1');
assert.equal(din.service_mode, 'dine_in');
assert.equal(din.service_tag, '261');
assert.equal(din.idempotency_key, 'key-1');
assert.equal(din.items.length, 1);
const take = buildOrderPayload(cart, 'a-emporter', '', {}, 'key-2');
assert.equal(take.service_mode, 'takeaway');
assert.equal('service_tag' in take, false);
});
/* --- submitOrder (mocks) ------------------------------------------------- */
function stubEnv(cart, mode) {
const store = { wakdo_cart: JSON.stringify(cart), wakdo_mode: mode };
global.localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
};
}
test('submitOrder: re-fetch slots, POST create puis pay, renvoie order_number', async () => {
stubEnv([{ id: 1, type: 'menu', quantite: 1, supplement_cents: 0, composition: { boisson: { id: 14 }, accompagnement: { id: 22 } } }], 'sur-place');
const calls = [];
global.fetch = async (url, opts) => {
calls.push({ url, method: opts?.method, body: opts?.body ? JSON.parse(opts.body) : null });
if (url === '/api/menus/1') return { ok: true, json: async () => ({ data: { slots: slots() } }) };
if (url === '/api/orders') return { ok: true, json: async () => ({ data: { order_number: 'K12', total_ttc_cents: 800, status: 'pending_payment' } }) };
if (url === '/api/orders/K12/pay') return { ok: true, json: async () => ({ data: { order_number: 'K12', total_ttc_cents: 800, status: 'paid' } }) };
throw new Error(`URL inattendue: ${url}`);
};
const res = await submitOrder({ serviceTag: '261' });
assert.equal(res.order_number, 'K12');
assert.equal(res.total_ttc_cents, 800);
const create = calls.find(c => c.url === '/api/orders');
assert.equal(create.method, 'POST');
assert.equal(create.body.service_mode, 'dine_in');
assert.equal(create.body.service_tag, '261');
assert.equal(create.body.items[0].type, 'menu');
assert.equal(create.body.items[0].selections.length, 2);
assert.ok(calls.some(c => c.url === '/api/orders/K12/pay' && c.method === 'POST'));
});
test('submitOrder: panier vide -> jette EMPTY_CART', async () => {
stubEnv([], 'a-emporter');
await assert.rejects(() => submitOrder(), /EMPTY_CART/);
});