From 26672b1467b8d2e60d78762a7957b8dd3bcd1dd0 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Tue, 16 Jun 2026 13:17:08 +0000 Subject: [PATCH] feat(admin): CRUD menus composes avec slots (P3, mlt 8.4-8.6) Quatrieme CRUD back-office, cas le plus riche du catalogue : un menu = burger de base + N slots de composition (menu_slot) chacun proposant M options eligibles (menu_slot_option), avec tarification Normal/Maxi. - App\Catalogue\MenuRepository : all/find, slotsWithOptions (LEFT JOIN regroupe), create + update dans UNE transaction (RG-T08) ; update reconstruit les slots en delete-and-reinsert (mlt 8.5 RG-2) ; delete FK-safe (order_item.menu_id RESTRICT, CASCADE vers slots/options) ; setActive ; categoryExists/productExists/isReferencedByOrders. - App\Controllers\MenuController : index/create/store/edit/update/toggle/confirmDelete/ destroy ; guard menu.read/create/update/delete (RG-T03) ; CSRF sur les mutations (RG-T01) ; validation bornee + allowlist (RG-T16/T18). PIN equipier + audit_log UNIQUEMENT a la suppression (mlt 8.6, RG-T13/T14) ; create/update sans PIN (un menu n'a pas de vat_rate). Throttle PIN RG-T22 (gate-before-verify, echec PIN trace + compte dans UNE transaction). - Vues admin/menus/{index,form,delete} + assets/js/menu-form.js : builder de slots vanilla JS (CSP 'self' : donnees via data-*, etat serialise en champ cache slots_json car Request::formBody ne garde que les scalaires). - Routes /admin/menus dans le front controller ; lien nav Menus reactive (menu.read). - Tests : MenuControllerTest (12 cas : guard, slots, CSRF, toggle, flux PIN+audit atomique sur delete, 422 FK) ; MenuRepositoryDbTest (integration, vraie DB : create+slots, slotsWithOptions, update delete-and-reinsert, delete+cascade) ; FakeDatabase etendu (menu). Suite : 201 tests verts (597 assertions avec WAKDO_DB_TESTS=1), PHPStan L6 propre. --- src/app/Catalogue/MenuRepository.php | 235 +++++++++ src/app/Controllers/MenuController.php | 535 +++++++++++++++++++++ src/app/Views/admin/layout.php | 9 +- src/app/Views/admin/menus/delete.php | 54 +++ src/app/Views/admin/menus/form.php | 138 ++++++ src/app/Views/admin/menus/index.php | 76 +++ src/public/admin/assets/js/menu-form.js | 160 ++++++ src/public/admin/index.php | 14 + tests/Integration/MenuRepositoryDbTest.php | 137 ++++++ tests/Support/FakeDatabase.php | 40 ++ tests/Unit/Admin/MenuControllerTest.php | 329 +++++++++++++ 11 files changed, 1724 insertions(+), 3 deletions(-) create mode 100644 src/app/Catalogue/MenuRepository.php create mode 100644 src/app/Controllers/MenuController.php create mode 100644 src/app/Views/admin/menus/delete.php create mode 100644 src/app/Views/admin/menus/form.php create mode 100644 src/app/Views/admin/menus/index.php create mode 100644 src/public/admin/assets/js/menu-form.js create mode 100644 tests/Integration/MenuRepositoryDbTest.php create mode 100644 tests/Unit/Admin/MenuControllerTest.php diff --git a/src/app/Catalogue/MenuRepository.php b/src/app/Catalogue/MenuRepository.php new file mode 100644 index 0000000..20f67a2 --- /dev/null +++ b/src/app/Catalogue/MenuRepository.php @@ -0,0 +1,235 @@ + la suppression dure est bloquee si le menu + * est reference par une commande historique (mlt 8.6 RG-1 : le controleur + * traduit la violation en 422 et propose la desactivation). + * + * create() et update() ecrivent menu + slots + options dans UNE transaction + * (RG-T08). update() reconstruit les slots en delete-and-reinsert (mlt 8.5 RG-2). + */ +final class MenuRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Liste pour le back-office, avec le libelle de categorie et le nom du burger. + * + * @return array> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT m.id, m.category_id, m.burger_product_id, m.name, m.price_normal_cents, ' + . 'm.price_maxi_cents, m.is_available, m.display_order, ' + . 'c.name AS category_name, p.name AS burger_name ' + . 'FROM menu m ' + . 'JOIN category c ON c.id = m.category_id ' + . 'JOIN product p ON p.id = m.burger_product_id ' + . 'ORDER BY m.display_order, m.name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, category_id, burger_product_id, name, price_normal_cents, ' + . 'price_maxi_cents, is_available, display_order FROM menu WHERE id = :id', + ['id' => $id], + ); + } + + /** + * Slots d'un menu (ordonnes), chacun avec la liste de ses product_id eligibles. + * Une seule requete (LEFT JOIN) regroupee en PHP par slot. + * + * @return list}> + */ + public function slotsWithOptions(int $menuId): array + { + $rows = $this->db->fetchAll( + 'SELECT s.id, s.name, s.slot_type, s.is_required, s.display_order, o.product_id ' + . 'FROM menu_slot s ' + . 'LEFT JOIN menu_slot_option o ON o.menu_slot_id = s.id ' + . 'WHERE s.menu_id = :id ORDER BY s.display_order, s.id', + ['id' => $menuId], + ); + + /** @var array}> $slots */ + $slots = []; + foreach ($rows as $r) { + $sid = (int) ($r['id'] ?? 0); + if (!isset($slots[$sid])) { + $slots[$sid] = [ + 'id' => $sid, + 'name' => (string) ($r['name'] ?? ''), + 'slot_type' => (string) ($r['slot_type'] ?? ''), + 'is_required' => (int) ($r['is_required'] ?? 0), + 'display_order' => (int) ($r['display_order'] ?? 0), + 'option_product_ids' => [], + ]; + } + if (($r['product_id'] ?? null) !== null) { + $slots[$sid]['option_product_ids'][] = (int) $r['product_id']; + } + } + + return array_values($slots); + } + + public function categoryExists(int $id): bool + { + return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $id]) !== null; + } + + public function productExists(int $id): bool + { + return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null; + } + + /** + * Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une + * ligne de commande historique ? La FK order_item.menu_id est RESTRICT. + */ + public function isReferencedByOrders(int $id): bool + { + return $this->db->fetch('SELECT menu_id FROM order_item WHERE menu_id = :id LIMIT 1', ['id' => $id]) !== null; + } + + /** + * Cree le menu et sa configuration de slots dans UNE transaction (mlt 8.4 RG-2). + * Retourne l'id du menu cree. + * + * @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data + * @param list}> $slots + */ + public function create(array $data, array $slots): int + { + $newId = 0; + $this->db->transaction(function (DatabaseInterface $db) use ($data, $slots, &$newId): void { + $db->execute( + 'INSERT INTO menu (category_id, burger_product_id, name, price_normal_cents, ' + . 'price_maxi_cents, is_available, display_order) ' + . 'VALUES (:category, :burger, :name, :pnormal, :pmaxi, :available, :ord)', + $this->bindMenu($data), + ); + $newId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + $this->insertSlots($db, $newId, $slots); + }); + + return $newId; + } + + /** + * Met a jour le menu et RECONSTRUIT ses slots (delete-and-reinsert, mlt 8.5 + * RG-2) dans UNE transaction : un edit de la configuration de slots est plus + * simple et sur a re-poser entierement qu'a reconcilier en place. + * + * @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data + * @param list}> $slots + */ + public function update(int $id, array $data, array $slots): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $data, $slots): void { + $db->execute( + 'UPDATE menu SET category_id = :category, burger_product_id = :burger, name = :name, ' + . 'price_normal_cents = :pnormal, price_maxi_cents = :pmaxi, is_available = :available, ' + . 'display_order = :ord WHERE id = :id', + $this->bindMenu($data) + ['id' => $id], + ); + // Options d'abord (FK vers slot), puis slots, puis re-insertion. + $db->execute( + 'DELETE FROM menu_slot_option WHERE menu_slot_id IN ' + . '(SELECT id FROM menu_slot WHERE menu_id = :id)', + ['id' => $id], + ); + $db->execute('DELETE FROM menu_slot WHERE menu_id = :id', ['id' => $id]); + $this->insertSlots($db, $id, $slots); + }); + } + + /** + * Suppression dure. CASCADE retire menu_slot + menu_slot_option ; + * order_item.menu_id (RESTRICT) bloque si une commande historique reference le + * menu (le controleur attrape SQLSTATE 23000 -> 422). + */ + public function delete(int $id): int + { + return $this->db->execute('DELETE FROM menu WHERE id = :id', ['id' => $id]); + } + + public function setActive(int $id, bool $active): int + { + return $this->db->execute( + 'UPDATE menu SET is_available = :a WHERE id = :id', + ['a' => $active ? 1 : 0, 'id' => $id], + ); + } + + /** + * Insere les slots d'un menu et leurs options (helper partage create/update). + * + * @param list}> $slots + */ + private function insertSlots(DatabaseInterface $db, int $menuId, array $slots): void + { + foreach ($slots as $slot) { + $db->execute( + 'INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) ' + . 'VALUES (:menu, :name, :type, :required, :ord)', + [ + 'menu' => $menuId, + 'name' => $slot['name'], + 'type' => $slot['slot_type'], + 'required' => $slot['is_required'], + 'ord' => $slot['display_order'], + ], + ); + $slotId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + foreach ($slot['options'] as $productId) { + $db->execute( + 'INSERT INTO menu_slot_option (menu_slot_id, product_id) VALUES (:slot, :product)', + ['slot' => $slotId, 'product' => $productId], + ); + } + } + } + + /** + * Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees. + * + * @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data + * @return array + */ + private function bindMenu(array $data): array + { + return [ + 'category' => $data['category_id'], + 'burger' => $data['burger_product_id'], + 'name' => $data['name'], + 'pnormal' => $data['price_normal_cents'], + 'pmaxi' => $data['price_maxi_cents'], + 'available' => $data['is_available'], + 'ord' => $data['display_order'], + ]; + } +} diff --git a/src/app/Controllers/MenuController.php b/src/app/Controllers/MenuController.php new file mode 100644 index 0000000..955aa55 --- /dev/null +++ b/src/app/Controllers/MenuController.php @@ -0,0 +1,535 @@ + hors RG-T13) ; + * - delete (menu.delete) : action sensible -> PIN equipier + audit (RG-T13/T14, + * mlt 8.6), suppression dure seulement si non reference par order_item.menu_id + * (FK RESTRICT -> 422 sinon, proposer la desactivation). + * + * La configuration de slots est soumise en un champ cache `slots_json` (le + * builder vanilla JS la serialise) : Request::formBody() ne retient que les + * scalaires, donc une structure imbriquee passe par du JSON valide cote serveur. + * + * Non `final` : les tests sous-classent pour injecter des doubles. + */ +class MenuController extends AdminController +{ + private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra']; + + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('menu.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/menus/index', [ + 'title' => 'Menus - Wakdo Admin', + 'activeNav' => 'menus', + 'menus' => $this->menuRepository()->all(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('menu.create'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('menu.create'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $slots, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $slots, $errors, 422); + } + + $this->menuRepository()->create($data, $slots); + $this->setFlash('Menu cree.'); + + return $this->redirect('/admin/menus'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('menu.update'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + $slots = $this->menuRepository()->slotsWithOptions($id); + + return $this->renderForm($guard, $id, $menu, $this->slotsToForm($slots), []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('menu.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + if ($this->menuRepository()->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $slots, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $slots, $errors, 422); + } + + $this->menuRepository()->update($id, $data, $slots); + $this->setFlash('Menu mis a jour.'); + + return $this->redirect('/admin/menus'); + } + + /** + * @param array $params + */ + public function toggle(array $params): Response + { + $guard = $this->guard('menu.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + $this->menuRepository()->setActive($id, (int) ($menu['is_available'] ?? 0) !== 1); + $this->setFlash('Disponibilite du menu mise a jour.'); + + return $this->redirect('/admin/menus'); + } + + /** + * @param array $params + */ + public function confirmDelete(array $params): Response + { + $guard = $this->guard('menu.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + return $this->renderDelete($guard, $id, $menu, null); + } + + /** + * @param array $params + */ + public function destroy(array $params): Response + { + $guard = $this->guard('menu.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + // RG-T22 : verrou de throttle PIN par utilisateur AGISSANT (session), evalue + // AVANT la verification ; un acteur verrouille recoit le meme 422 generique, + // on paie un leurre de timing et on n'ecrit pas de pin.failed sous verrou. + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderDelete($guard, $id, $menu, 'Email ou PIN invalide (requis pour supprimer).'); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans + // UNE meme transaction (pas d'etat partiel si crash entre les deux). + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void { + $this->logFailedPin($db, $email, $id); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderDelete($guard, $id, $menu, 'Email ou PIN invalide (requis pour supprimer).'); + } + + $name = (string) ($menu['name'] ?? ''); + + // FK order_item.menu_id RESTRICT -> PDOException 23000 -> 422 (catch). + // menu_slot / menu_slot_option sont CASCADE (supprimes avec le menu). + try { + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void { + $deleted = (new MenuRepository($db))->delete($id); + if ($deleted === 1) { + $this->writeAudit($db, 'menu.delete', $actor['id'], $actor['role_id'], $id, 'Suppression menu: ' . $name); + } + }); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderDelete($guard, $id, $menu, 'Menu reference par des commandes : suppression impossible. Desactivez-le plutot.'); + } + + throw $exception; + } + + // PIN valide + suppression effective : reset du compteur de l'acteur de + // SESSION (RG-T22, cle = $actorId, pas l'acteur resolu par le PIN). + $this->pinThrottle()->reset($actorId); + + $this->setFlash('Menu supprime.'); + + return $this->redirect('/admin/menus'); + } + + protected function menuRepository(): MenuRepository + { + return new MenuRepository($this->db()); + } + + protected function productRepository(): ProductRepository + { + return new ProductRepository($this->db()); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees menu, + * slots normalises, erreurs]. Les slots viennent du champ cache slots_json. + * + * @param array $form + * @return array{0: array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int}, 1: list}>, 2: array} + */ + private function validate(array $form): array + { + $errors = []; + + $categoryRaw = trim($form['category_id'] ?? ''); + $categoryId = ctype_digit($categoryRaw) ? (int) $categoryRaw : 0; + if ($categoryId === 0 || !$this->menuRepository()->categoryExists($categoryId)) { + $errors['category_id'] = 'Categorie requise et valide.'; + } + + $burgerRaw = trim($form['burger_product_id'] ?? ''); + $burgerId = ctype_digit($burgerRaw) ? (int) $burgerRaw : 0; + if ($burgerId === 0 || !$this->menuRepository()->productExists($burgerId)) { + $errors['burger_product_id'] = 'Le produit burger de base est requis et doit exister.'; + } + + $name = trim($form['name'] ?? ''); + if ($name === '' || mb_strlen($name) > 120) { + $errors['name'] = 'Le nom est requis (120 caracteres max).'; + } + + $priceNormal = $this->parsePrice($form['price_normal_cents'] ?? ''); + if ($priceNormal === null) { + $errors['price_normal_cents'] = 'Le prix Normal (centimes) doit etre un entier strictement positif.'; + } + + $priceMaxi = $this->parsePrice($form['price_maxi_cents'] ?? ''); + if ($priceMaxi === null) { + $errors['price_maxi_cents'] = 'Le prix Maxi (centimes) doit etre un entier strictement positif.'; + } + + $orderRaw = trim($form['display_order'] ?? '0'); + $displayOrder = ctype_digit($orderRaw) && (int) $orderRaw <= 65535 ? (int) $orderRaw : -1; + if ($displayOrder < 0) { + $errors['display_order'] = 'L\'ordre d\'affichage doit etre un entier entre 0 et 65535.'; + } + + $slots = $this->parseSlots($form['slots_json'] ?? '', $errors); + + $data = [ + 'category_id' => $categoryId, + 'burger_product_id' => $burgerId, + 'name' => $name, + 'price_normal_cents' => $priceNormal ?? 0, + 'price_maxi_cents' => $priceMaxi ?? 0, + 'is_available' => isset($form['is_available']) ? 1 : 0, + 'display_order' => $displayOrder < 0 ? 0 : $displayOrder, + ]; + + return [$data, $slots, $errors]; + } + + /** + * Decode + valide la configuration de slots soumise en JSON. Precondition + * mlt 8.4 : >=1 slot avec >=1 option ; chaque option doit exister. + * + * @param array $errors + * @return list}> + */ + private function parseSlots(string $json, array &$errors): array + { + if (trim($json) === '') { + $errors['slots'] = 'Au moins un slot avec au moins une option est requis.'; + + return []; + } + + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + if (!is_array($decoded) || $decoded === []) { + $errors['slots'] = 'Configuration de slots invalide.'; + + return []; + } + + $slots = []; + $order = 0; + foreach ($decoded as $raw) { + if (!is_array($raw)) { + continue; + } + + $slotName = is_string($raw['name'] ?? null) ? trim($raw['name']) : ''; + $slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : ''; + $required = !empty($raw['is_required']) ? 1 : 0; + + $optionIds = []; + foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) { + $pid = is_numeric($opt) ? (int) $opt : 0; + if ($pid > 0 && $this->menuRepository()->productExists($pid)) { + $optionIds[] = $pid; + } + } + $optionIds = array_values(array_unique($optionIds)); + + if ($slotName === '' || mb_strlen($slotName) > 80) { + $errors['slots'] = 'Chaque slot doit avoir un nom (80 caracteres max).'; + continue; + } + if (!in_array($slotType, self::SLOT_TYPES, true)) { + $errors['slots'] = 'Type de slot invalide.'; + continue; + } + if ($optionIds === []) { + $errors['slots'] = 'Chaque slot doit proposer au moins une option valide.'; + continue; + } + + $slots[] = [ + 'name' => $slotName, + 'slot_type' => $slotType, + 'is_required' => $required, + 'display_order' => $order++, + 'options' => $optionIds, + ]; + } + + if ($slots === [] && !isset($errors['slots'])) { + $errors['slots'] = 'Au moins un slot avec au moins une option est requis.'; + } + + return $slots; + } + + private function parsePrice(string $raw): ?int + { + $raw = trim($raw); + + return ctype_digit($raw) && (int) $raw > 0 && (int) $raw <= 4294967295 ? (int) $raw : null; + } + + /** + * Transforme les slots charges (repository) en structure JSON pour pre-remplir + * le builder a l'edition. + * + * @param list}> $slots + * @return list}> + */ + private function slotsToForm(array $slots): array + { + return array_map(static fn (array $s): array => [ + 'name' => $s['name'], + 'slot_type' => $s['slot_type'], + 'is_required' => $s['is_required'], + 'options' => $s['option_product_ids'], + ], $slots); + } + + /** + * @param array $values valeurs du menu (re-rendu) ou row trouvee + * @param list> $slots slots pre-remplis (structure JSON) + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $slots, array $errors, int $status = 200): Response + { + return $this->adminView('admin/menus/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' menu - Wakdo Admin', + 'activeNav' => 'menus', + 'menuId' => $id, + 'categories' => $this->categoryRepository()->all(), + 'products' => $this->productRepository()->all(), + 'slotTypes' => self::SLOT_TYPES, + 'values' => [ + 'category_id' => (string) ($values['category_id'] ?? ''), + 'burger_product_id' => (string) ($values['burger_product_id'] ?? ''), + 'name' => (string) ($values['name'] ?? ''), + 'price_normal_cents' => (string) ($values['price_normal_cents'] ?? ''), + 'price_maxi_cents' => (string) ($values['price_maxi_cents'] ?? ''), + 'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values), + 'display_order' => (string) ($values['display_order'] ?? '0'), + ], + 'slotsJson' => json_encode($slots, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]', + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $menu + */ + private function renderDelete(GuardResult $guard, int $id, array $menu, ?string $error): Response + { + return $this->adminView('admin/menus/delete', [ + 'title' => 'Supprimer un menu - Wakdo Admin', + 'activeNav' => 'menus', + 'menuId' => $id, + 'name' => (string) ($menu['name'] ?? ''), + 'error' => $error, + ], $guard, $error !== null ? 422 : 200); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'menus'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + /** + * Trace une tentative de PIN echouee sur une action sensible (RG-T14), acteur + * inconnu (PIN non resolu). Recoit le $db de la transaction (atomicite RG-T08). + */ + private function logFailedPin(DatabaseInterface $db, string $email, int $menuId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'menu', + 'eid' => $menuId, + 'summary' => 'Echec PIN action sensible (email tente: ' . $email . ')', + ], + ); + } + + private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + ['uid' => $userId, 'rid' => $roleId, 'code' => $action, 'etype' => 'menu', 'eid' => $entityId, 'summary' => $summary], + ); + } +} diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index d53d036..a8f174a 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -96,7 +96,7 @@ $navClass = static function (string $code, string $current): string { Tableau de bord - + diff --git a/src/app/Views/admin/menus/delete.php b/src/app/Views/admin/menus/delete.php new file mode 100644 index 0000000..24d8dc7 --- /dev/null +++ b/src/app/Views/admin/menus/delete.php @@ -0,0 +1,54 @@ + + + +
+ +

+ + +
+ + +

La suppression est tracee (audit) et retire aussi les slots du menu. Renseignez votre email et votre PIN.

+ +
+ + +
+ +
+ + +
+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/menus/form.php b/src/app/Views/admin/menus/form.php new file mode 100644 index 0000000..de4ba78 --- /dev/null +++ b/src/app/Views/admin/menus/form.php @@ -0,0 +1,138 @@ +> $categories + * @var array> $products + * @var list $slotTypes + * @var array $values + * @var string $slotsJson + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($menuId ?? 0); +$action = $id !== 0 ? '/admin/menus/' . $id : '/admin/menus'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $cats */ +$cats = isset($categories) && is_array($categories) ? $categories : []; +/** @var array> $prods */ +$prods = isset($products) && is_array($products) ? $products : []; +/** @var list $types */ +$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$selectedCat = (string) ($vals['category_id'] ?? ''); +$selectedBurger = (string) ($vals['burger_product_id'] ?? ''); +$available = (bool) ($vals['is_available'] ?? true); + +// Donnees pour le builder JS, passees en attributs data-* (CSP 'self' : pas de +// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut. +$slimProducts = array_map( + static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')], + $prods, +); +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); +$slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $slotsJson : '[]'; +?> + + + + diff --git a/src/app/Views/admin/menus/index.php b/src/app/Views/admin/menus/index.php new file mode 100644 index 0000000..60e3fdb --- /dev/null +++ b/src/app/Views/admin/menus/index.php @@ -0,0 +1,76 @@ +> $menus + * @var string $csrfToken + */ + +/** @var array> $rows */ +$rows = isset($menus) && is_array($menus) ? $menus : []; +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR'; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NomCategorieBurger de basePrix (Normal – Maxi)Statut
Aucun menu.
+ + Disponible + + Indisponible + + + Modifier +
+ + +
+ Supprimer +
+
+
diff --git a/src/public/admin/assets/js/menu-form.js b/src/public/admin/assets/js/menu-form.js new file mode 100644 index 0000000..a45f909 --- /dev/null +++ b/src/public/admin/assets/js/menu-form.js @@ -0,0 +1,160 @@ +/* + * menu-form.js — Builder de slots du formulaire menu (back-office). + * + * CSP 'self' : script externe (pas d'inline). Les donnees (produits, types, + * slots initiaux) sont lues depuis les attributs data-* de #slot-builder. + * A la soumission, l'etat des slots est serialise en JSON dans le champ cache + * #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou + * le passage par une chaine JSON). Le serveur revalide tout (RG-T18). + */ +(function () { + 'use strict'; + + var builder = document.getElementById('slot-builder'); + var form = document.getElementById('menu-form'); + var hidden = document.getElementById('slots_json'); + var addBtn = document.getElementById('add-slot'); + if (!builder || !form || !hidden || !addBtn) { + return; + } + + function parseData(key, fallback) { + try { + var v = JSON.parse(builder.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + var products = parseData('products', '[]'); // [{id, name}] + var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...] + var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}] + + function el(tag, className) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + // Construit le bloc DOM d'un slot. `slot` peut etre vide (creation). + function renderSlot(slot) { + slot = slot || {}; + var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : []; + + var block = el('fieldset', 'slot-block form-group'); + block.style.border = '1px solid #ddd'; + block.style.padding = '0.75rem'; + block.style.marginBottom = '0.75rem'; + + var head = el('div'); + + // Nom du slot + var nameLabel = el('label'); + nameLabel.appendChild(document.createTextNode('Nom du slot ')); + var nameInput = el('input', 'form-input slot-name'); + nameInput.type = 'text'; + nameInput.maxLength = 80; + nameInput.value = slot.name ? String(slot.name) : ''; + nameLabel.appendChild(nameInput); + head.appendChild(nameLabel); + + // Type + var typeLabel = el('label'); + typeLabel.appendChild(document.createTextNode(' Type ')); + var typeSelect = el('select', 'form-input slot-type'); + slotTypes.forEach(function (t) { + var opt = el('option'); + opt.value = String(t); + opt.textContent = String(t); + if (String(slot.slot_type) === String(t)) { + opt.selected = true; + } + typeSelect.appendChild(opt); + }); + typeLabel.appendChild(typeSelect); + head.appendChild(typeLabel); + + // Requis + var reqLabel = el('label'); + var reqInput = el('input', 'slot-required'); + reqInput.type = 'checkbox'; + if (Number(slot.is_required) === 1) { + reqInput.checked = true; + } + reqLabel.appendChild(reqInput); + reqLabel.appendChild(document.createTextNode(' Requis')); + head.appendChild(reqLabel); + + // Retirer + var removeBtn = el('button', 'btn btn-secondary slot-remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + block.parentNode.removeChild(block); + }); + head.appendChild(removeBtn); + + block.appendChild(head); + + // Options : cases a cocher des produits eligibles + var optWrap = el('div', 'slot-options'); + optWrap.style.maxHeight = '160px'; + optWrap.style.overflowY = 'auto'; + optWrap.style.marginTop = '0.5rem'; + products.forEach(function (p) { + var lab = el('label'); + lab.style.display = 'block'; + var cb = el('input', 'slot-option'); + cb.type = 'checkbox'; + cb.value = String(p.id); + if (selectedOptions.indexOf(Number(p.id)) !== -1) { + cb.checked = true; + } + lab.appendChild(cb); + lab.appendChild(document.createTextNode(' ' + String(p.name))); + optWrap.appendChild(lab); + }); + block.appendChild(optWrap); + + return block; + } + + // Lit l'etat des blocs et le serialise dans #slots_json. + function serialize() { + var slots = []; + var blocks = builder.querySelectorAll('.slot-block'); + Array.prototype.forEach.call(blocks, function (block) { + var name = block.querySelector('.slot-name').value.trim(); + var type = block.querySelector('.slot-type').value; + var required = block.querySelector('.slot-required').checked ? 1 : 0; + var options = []; + Array.prototype.forEach.call(block.querySelectorAll('.slot-option'), function (cb) { + if (cb.checked) { + options.push(Number(cb.value)); + } + }); + slots.push({ name: name, slot_type: type, is_required: required, options: options }); + }); + hidden.value = JSON.stringify(slots); + } + + addBtn.addEventListener('click', function () { + builder.appendChild(renderSlot(null)); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + // Rendu initial : slots existants (edition) ou un slot vide (creation). + if (initialSlots.length) { + initialSlots.forEach(function (s) { + builder.appendChild(renderSlot(s)); + }); + } else { + builder.appendChild(renderSlot(null)); + } +})(); diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 7259dac..231e2f4 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -17,6 +17,7 @@ use App\Controllers\DashboardController; use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\MeController; +use App\Controllers\MenuController; use App\Controllers\PasswordResetController; use App\Controllers\ProductController; use App\Controllers\ProfileController; @@ -90,6 +91,19 @@ try { $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); + // CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base + + // slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression + // (mlt 8.6) ; create/update sans PIN. {id} = un seul segment, pas de collision + // avec /toggle ni /delete. + $router->add('GET', '/admin/menus', [MenuController::class, 'index']); + $router->add('GET', '/admin/menus/new', [MenuController::class, 'create']); + $router->add('POST', '/admin/menus', [MenuController::class, 'store']); + $router->add('GET', '/admin/menus/{id}/edit', [MenuController::class, 'edit']); + $router->add('POST', '/admin/menus/{id}', [MenuController::class, 'update']); + $router->add('POST', '/admin/menus/{id}/toggle', [MenuController::class, 'toggle']); + $router->add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']); + $router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/MenuRepositoryDbTest.php b/tests/Integration/MenuRepositoryDbTest.php new file mode 100644 index 0000000..5cd098a --- /dev/null +++ b/tests/Integration/MenuRepositoryDbTest.php @@ -0,0 +1,137 @@ + */ + private array $productIds = []; + + protected function setUp(): void + { + if (getenv('WAKDO_DB_TESTS') !== '1') { + self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).'); + } + + $this->db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->categoryId = (int) ($this->db->fetch('SELECT id FROM category ORDER BY id LIMIT 1')['id'] ?? 0); + $this->productIds = array_map( + static fn (array $r): int => (int) ($r['id'] ?? 0), + $this->db->fetchAll('SELECT id FROM product ORDER BY id LIMIT 3'), + ); + $this->name = 'it-menu-' . bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if ($this->name !== '') { + // CASCADE menu -> menu_slot -> menu_slot_option. + $this->db->execute('DELETE FROM menu WHERE name = :name', ['name' => $this->name]); + } + } + + public function testCreateFindUpdateSlotsAndDelete(): void + { + self::assertGreaterThan(0, $this->categoryId); + self::assertCount(3, $this->productIds); + [$burger, $optA, $optB] = $this->productIds; + + $repo = new MenuRepository($this->db); + self::assertTrue($repo->categoryExists($this->categoryId)); + self::assertTrue($repo->productExists($burger)); + self::assertFalse($repo->productExists(0)); + + // --- create : menu + 2 slots (drink avec 2 options, side avec 1) --- + $id = $repo->create( + [ + 'category_id' => $this->categoryId, + 'burger_product_id' => $burger, + 'name' => $this->name, + 'price_normal_cents' => 790, + 'price_maxi_cents' => 990, + 'is_available' => 1, + 'display_order' => 50, + ], + [ + ['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 0, 'options' => [$optA, $optB]], + ['name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'options' => [$optA]], + ], + ); + self::assertGreaterThan(0, $id); + + $found = $repo->find($id); + self::assertNotNull($found); + self::assertSame(790, (int) ($found['price_normal_cents'] ?? 0)); + self::assertSame(990, (int) ($found['price_maxi_cents'] ?? 0)); + + $slots = $repo->slotsWithOptions($id); + self::assertCount(2, $slots); + self::assertSame('drink', $slots[0]['slot_type']); + self::assertEqualsCanonicalizing([$optA, $optB], $slots[0]['option_product_ids']); + self::assertSame('side', $slots[1]['slot_type']); + self::assertSame([$optA], $slots[1]['option_product_ids']); + + // all() porte categorie + burger joints. + $names = array_map(static fn (array $r): string => (string) ($r['name'] ?? ''), $repo->all()); + self::assertContains($this->name, $names); + + self::assertFalse($repo->isReferencedByOrders($id)); + + // --- update : change le prix maxi ET reconfigure en 1 SEUL slot --- + // (verifie le delete-and-reinsert : les 2 anciens slots disparaissent). + $repo->update( + $id, + [ + 'category_id' => $this->categoryId, + 'burger_product_id' => $burger, + 'name' => $this->name, + 'price_normal_cents' => 790, + 'price_maxi_cents' => 1090, + 'is_available' => 0, + 'display_order' => 51, + ], + [ + ['name' => 'Sauce', 'slot_type' => 'sauce', 'is_required' => 0, 'display_order' => 0, 'options' => [$optB]], + ], + ); + + $updated = $repo->find($id); + self::assertNotNull($updated); + self::assertSame(1090, (int) ($updated['price_maxi_cents'] ?? 0)); + self::assertSame(0, (int) ($updated['is_available'] ?? 1)); + + $slotsAfter = $repo->slotsWithOptions($id); + self::assertCount(1, $slotsAfter); // delete-and-reinsert : plus que 1 slot + self::assertSame('sauce', $slotsAfter[0]['slot_type']); + self::assertSame([$optB], $slotsAfter[0]['option_product_ids']); + + // --- delete : menu non reference -> suppression dure OK, slots cascade --- + self::assertSame(1, $repo->delete($id)); + self::assertNull($repo->find($id)); + self::assertSame([], $repo->slotsWithOptions($id)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index c3d0051..cdf0d6e 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -134,6 +134,30 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $productRow = null; + /** + * Ligne renvoyee par MenuRepository::find() ; null = introuvable. + * + * @var array|null + */ + public ?array $menuRow = null; + + /** + * Lignes renvoyees par MenuRepository::all(). + * + * @var list> + */ + public array $menusRows = []; + + /** + * Lignes (LEFT JOIN slot/option) renvoyees par MenuRepository::slotsWithOptions(). + * + * @var list> + */ + public array $menuSlotRows = []; + + /** Resultat de MenuRepository::isReferencedByOrders() (true = reference par une commande). */ + public bool $menuReferenced = false; + /** * Ligne renvoyee pour PinVerifier::resolveActingUser (id, role_id, pin_hash) ; * null = email inconnu/inactif. @@ -230,6 +254,14 @@ final class FakeDatabase implements DatabaseInterface return $this->categoryRow; } + if (str_contains($sql, 'FROM menu WHERE id = :id')) { + return $this->menuRow; + } + + if (str_contains($sql, 'FROM order_item WHERE menu_id')) { + return $this->menuReferenced ? ['menu_id' => 1] : null; + } + if (str_contains($sql, 'FROM category WHERE name = :name')) { return $this->categoryNameTaken ? ['id' => 1] : null; } @@ -269,6 +301,14 @@ final class FakeDatabase implements DatabaseInterface return $this->productsRows; } + if (str_contains($sql, 'FROM menu m JOIN category')) { + return $this->menusRows; + } + + if (str_contains($sql, 'FROM menu_slot s')) { + return $this->menuSlotRows; + } + if (str_contains($sql, 'SELECT p.code FROM role_permission')) { if (!$this->roleActive) { return []; diff --git a/tests/Unit/Admin/MenuControllerTest.php b/tests/Unit/Admin/MenuControllerTest.php new file mode 100644 index 0000000..3701850 --- /dev/null +++ b/tests/Unit/Admin/MenuControllerTest.php @@ -0,0 +1,329 @@ +testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } +} + +final class MenuControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + private SessionManager $session; + private string $csrf = ''; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 1); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur']; + $db->canResult = true; + $db->permissionCodes = ['menu.read', 'menu.create', 'menu.update', 'menu.delete']; + $db->categoryRow = ['id' => 1, 'name' => 'Menus']; // categoryExists -> true + $db->productRow = ['id' => 1, 'name' => 'Big Mac']; // productExists -> true (burger + options) + return $db; + } + + private function get(string $path): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5'); + } + + private function controller(Request $request, FakeDatabase $db): TestMenuController + { + return new TestMenuController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + /** + * @param array $overrides + * @return array + */ + private function validForm(array $overrides = []): array + { + $slots = (string) json_encode([ + ['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'options' => [1]], + ]); + + return array_merge([ + '_csrf' => $this->csrf, + 'category_id' => '1', + 'burger_product_id' => '1', + 'name' => 'Best Of', + 'price_normal_cents' => '790', + 'price_maxi_cents' => '990', + 'display_order' => '1', + 'is_available' => '1', + 'slots_json' => $slots, + ], $overrides); + } + + private function actingPin(FakeDatabase $db): void + { + $db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')]; + } + + public function testIndexRequiresMenuRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($this->get('/admin/menus'), $db)->index()->status()); + } + + public function testIndexListsMenus(): void + { + $db = $this->permittedDb(); + $db->menusRows = [ + ['id' => 1, 'category_id' => 1, 'burger_product_id' => 2, 'name' => 'Best Of Big Mac', 'price_normal_cents' => 790, 'price_maxi_cents' => 990, 'is_available' => 1, 'display_order' => 0, 'category_name' => 'Menus', 'burger_name' => 'Big Mac'], + ]; + + $response = $this->controller($this->get('/admin/menus'), $db)->index(); + self::assertSame(200, $response->status()); + self::assertStringContainsString('Best Of Big Mac', $response->body()); + self::assertStringContainsString('Nouveau menu', $response->body()); + } + + public function testStoreCreatesMenuWithSlots(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store(); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('INSERT INTO menu')); + self::assertTrue($db->wrote('INSERT INTO menu_slot')); + self::assertTrue($db->wrote('INSERT INTO menu_slot_option')); + self::assertFalse($db->wrote('INSERT INTO audit_log')); // create = pas d'action sensible (mlt 8.4) + self::assertSame('Menu cree.', $this->session->get('_flash')); + } + + public function testStoreRejectsWithoutSlots(): void + { + $db = $this->permittedDb(); + // Precondition mlt 8.4 : >=1 slot avec >=1 option. Ici aucun slot. + $response = $this->controller($this->post($this->validForm(['slots_json' => '[]']), '/admin/menus'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO menu')); + } + + public function testStoreRejectsSlotWithoutOption(): void + { + $db = $this->permittedDb(); + $slots = (string) json_encode([['name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'options' => []]]); + $response = $this->controller($this->post($this->validForm(['slots_json' => $slots]), '/admin/menus'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO menu')); + } + + public function testStoreRejectsInvalidCsrf(): void + { + $db = $this->permittedDb(); + $response = $this->controller($this->post($this->validForm(['_csrf' => 'wrong']), '/admin/menus'), $db)->store(); + + self::assertSame(403, $response->status()); + self::assertFalse($db->wrote('INSERT INTO menu')); + } + + public function testUpdateRebuildsSlots(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'category_id' => 1, 'burger_product_id' => 2, 'name' => 'Best Of', 'price_normal_cents' => 790, 'price_maxi_cents' => 990, 'is_available' => 1, 'display_order' => 0]; + + $response = $this->controller($this->post($this->validForm(), '/admin/menus/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('UPDATE menu SET')); + // delete-and-reinsert des slots (mlt 8.5 RG-2). + self::assertTrue($db->wrote('DELETE FROM menu_slot')); + self::assertTrue($db->wrote('INSERT INTO menu_slot')); + } + + public function testDestroyLockedActorReturns422WithoutDeletingOrAuditing(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $this->actingPin($db); + $db->pinThrottleLockoutUntil = '2099-01-01 00:00:00'; + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('DELETE FROM menu')); + self::assertSame([], $db->auditActions()); + } + + public function testDestroyWrongPinRecordsFailureOnSessionActor(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $db->actingUserRow = null; // email/PIN invalide + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame(['pin.failed'], $db->auditActions()); + self::assertTrue($db->wrote('INSERT INTO pin_throttle')); // RG-T22 increment sur l'agissant + // RG-T08 : pin.failed + increment throttle dans UNE transaction. + self::assertSame(['begin', 'commit'], $db->transactionEvents); + } + + public function testDestroyValidPinDeletesAuditsAndResets(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $this->actingPin($db); + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('DELETE FROM menu')); + self::assertSame(['menu.delete'], $db->auditActions()); + // L'audit porte l'acteur RESOLU PAR PIN (id 9), dans la transaction de l'effet. + $audit = $this->findWrite($db, 'INSERT INTO audit_log'); + self::assertNotNull($audit); + self::assertSame(9, $audit['params']['uid'] ?? null); + $this->assertAuditWithinTransaction($db); + // Reset du throttle sur l'acteur de SESSION (id 1). + $reset = $this->findWrite($db, 'UPDATE pin_throttle SET failed_attempts = 0'); + self::assertNotNull($reset); + self::assertSame(1, $reset['params']['uid'] ?? null); + } + + public function testDestroyReferencedByOrderReturns422(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of']; + $this->actingPin($db); + $db->failOnExecute = new PDOException('referenced', 23000); // FK order_item.menu_id RESTRICT + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/menus/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('suppression impossible', $response->body()); + } + + public function testToggleFlipsAvailability(): void + { + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Best Of', 'is_available' => 1]; + + $response = $this->controller($this->post(['_csrf' => $this->csrf], '/admin/menus/5/toggle'), $db)->toggle(['id' => '5']); + + self::assertSame(302, $response->status()); + $write = $this->findWrite($db, 'UPDATE menu SET is_available'); + self::assertNotNull($write); + self::assertSame(0, $write['params']['a'] ?? null); // 1 -> 0 + } + + /** + * @return array{sql: string, params: array}|null + */ + private function findWrite(FakeDatabase $db, string $needle): ?array + { + foreach ($db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + return null; + } + + private function assertAuditWithinTransaction(FakeDatabase $db): void + { + $log = $db->eventLog; + $begin = array_search('begin', $log, true); + $commit = array_search('commit', $log, true); + $auditAt = null; + foreach ($log as $i => $event) { + if (str_contains($event, 'INSERT INTO audit_log')) { + $auditAt = $i; + } + } + + self::assertIsInt($begin); + self::assertIsInt($commit); + self::assertNotNull($auditAt); + self::assertTrue($begin < $auditAt && $auditAt < $commit, 'audit_log doit etre ecrit entre begin et commit'); + } +} -- 2.45.3